revert(spec): take back the declaration-text snapshot, restore the 27 signature hashes - #19024
Conversation
… signature hashes This reverts commit d8b12fc. Executes the maintainer's ruling recorded verbatim on the card that carries this work: option C, revert the PR and let consumer compilation against spec@main be the shape gate instead. The direction is not re-argued here. One conflict, resolved mechanically. api-surface-declarations/root.txt was regenerated on main after the reverted commit; the revert deletes the whole directory, so that file goes with it (git rm). Nothing else in the reverted commit's file set needed a decision. The one path this branch does NOT restore to the reverted commit's parent is .github/workflows/lint.yml, which keeps the later, unrelated check:release-spec-changes step. Reverting that step is not this revert's business. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Two modify/delete conflicts, both the same mechanical shape as the one the revert itself carried: api-surface-declarations/automation.txt and data.txt were regenerated on main while this branch deletes the whole directory, so the files go with it (git rm). Nothing else in the merge needed a decision. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
|
Skills-lane reading of the Read at
This is a reading, not a review of record: the revert is the spec seat's (os-bill, #19011), Generated by Claude Code |
|
席位记录( ①
|
Main regenerated packages/spec/api-surface-declarations/ui.txt after this branch deleted the directory, so the merge raised the same modify/delete it raised once before. Resolved the same mechanical way: git rm, because the revert removes the whole directory. Re-verified on the merge result: of the 31 paths the reverted commit touched, 30 are byte-identical to its parent; the one that is not is .github/workflows/lint.yml, whose only difference is the check:release-spec-changes step a LATER commit added, compared hunk body to hunk body and identical. api-surface-signatures.json is back with its 27 top-level keys, defineAction through defineWebhook. Claude-Session: https://claude.ai/code/session_01JbZnqu8bt6YqfJsr9vaFb3 Co-authored-by: Claude <noreply@anthropic.com>
…artconfig-precedence-half-2 Resolved three modify/delete conflicts by taking main's DELETION: `packages/spec/api-surface-declarations/` was reverted off main whole by #19024 (the declaration-text snapshot is taken back and the 27 signature hashes restored), together with its `check:`/`gen:` scripts. This branch had only regenerated three of those files; with the artefact and its gate gone there is nothing for those edits to be about.⚠️ Committed BEFORE regenerating, per scripts/pm/os-regen-merge.sh step 3: the os-regen driver exits 0 while silently dropping one side, so the regeneration belongs in its own commit on a known-good base. Claude-Session: https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho Co-authored-by: Claude <noreply@anthropic.com>
…ggester-opposite-sibling Resolves 4 delete/modify conflicts in packages/spec/api-surface-declarations/ (api.txt, kernel.txt, root.txt, security.txt) by taking main's deletion: #19024 retired the whole declaration-text snapshot mechanism (script, package.json scripts, check:generated gate, files) and this branch had only modified those now-retired files. Verified check:api-surface and check:generated still pass, and that the branch's new polarity-axes.ts exports are not part of the public barrel (not re-exported from shared/index.ts), so no export-recording artifact needs a change. Co-Authored-By: Claude <noreply@anthropic.com>
…es option, and a pin reads the ruleset The size limb (and the governed limb beside it) prescribed 人工直合 — "the maintainer's own click" — without naming which click. Ruleset `main` mandates the merge queue and lists this guard as a required context, so the only Merge that is not an enqueue is the Merge button's bypass-rules option, offered only while the ruleset configures a bypass actor. With none configured the remedy named a terminal nobody could reach: PR #19024 was enqueued three times and refused three times. The four remedy sentences (the header's and the three rendered ones) now name that option and keep 人工直合 as the NAME of the act. A new self-test battery judges the remedy text against a RECORDED reading of `GET /repos/objectstack-ai/objectstack/rulesets/12119582`: red when `bypass_actors` is present and empty, red when the remedy drifts back to a bare click, and a pass that PRINTS its reading when the field is unreadable — which is what every seat and Actions token gets, `administration` being outside the 17 permissions a workflow may grant. The reading is recorded rather than fetched because this self-test is the required guard job's first step and is declared offline; a live read would answer "unreadable" on every CI run, asserting nothing while adding a network dependency to a merge precondition. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude <noreply@anthropic.com>
Resolves delete/modify conflicts on the five api-surface-declarations files this branch had modified (api.txt, data.txt, root.txt, system.txt, ui.txt) by taking main's deletion of the entire directory (commit 2277d1f, the #19024 revert). That mechanism is fully retired on main: no script regenerates or reads packages/spec/api-surface-declarations/ anymore, and the replacement mechanism (api-surface/*.json + api-surface-signatures.json) does not capture this PR's kind of change per its own documented scope (key-level narrowing inside a schema, not a factory-signature or export-kind change) — consistent with this PR never having touched those files. The actual carrier for this PR's surface change, authorable-surface/ui.json, merged and regenerated cleanly and still declares ui/BulkActionParam:dependsOn. Regenerated after the merge: packages/spec/authorable-surface/ (via gen:schema), content/docs/references/** (via gen:docs), docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (via gen:strictness-ledger), and src/migrations/registry.ts (via gen:migration-registry) — all previously flagged by the merge driver as generated/not-text-merged. Co-Authored-By: Claude <noreply@anthropic.com>
…opertynames-not-pattern-arm
Conflicts resolved:
- packages/spec/api-surface-declarations/{api,system}.txt (modify/delete):
took origin/main's deletion. #19024 reverted the declaration-text snapshot
wholesale on main - the generator (build-api-surface-declarations.ts), the
package.json scripts, the check-generated entry and the .gitattributes
merge=os-regen row are all gone, and api-surface-signatures.json is back.
This branch's only touch of those two files was a regeneration commit
("member order only"), so nothing hand-authored is lost by the deletion.
- packages/spec/dropped-refinements.baseline.json (content): the conflict was
confined to the `measured` census block. The entries map text-merged, so the
header counts describe neither side. Resolved to a committable state here;
the regeneration commit that follows carries the corrected entry the gate
itself prints.
Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
…ON clause is derived (objectstack-ai#18938) Fixes objectstack-ai#18612 Clause-②: yes (narrowing) Retires `sql` and `relationship` from `CubeJoin`. A cube join declares WHICH object it reaches; the ON clause is **derived** from the declared relationship between the two cubes' objects and is never authored. Per maintainer ruling `5725370783` (director batch objectstack-ai#154 item 4, letter 2), ADR-0049 enforce-or-remove. The other remedy — executing the author's SQL — was declined by that ruling and is ⛔ not reopened here. ## What this head carries — round 3 closed the one gap The gap the earlier body described (`check:adr-0087-registration` RED on purpose, and a fence on `packages/spec/src/migrations/registry.ts`) is **gone**. The maintainer answered that fork with **A — lift the fence**, and the registration is now in-diff: | what | where | |:---|:---| | D3 semantic entry `cube-join-sql-and-relationship-retired` | `packages/spec/src/migrations/entries/semantic/18.*` | | D2 conversion `cube-join-sql-and-relationship-removed` | `packages/spec/src/conversions/registry.ts`, chained into `step18.conversionIds` | | `RETIRED_KEYS_BY_MAJOR[18]` gains `data/CubeJoin:sql` and `data/CubeJoin:relationship` | the two per-file retired-key entries | | the changeset's disposition marker | `<!-- adr-0087: registered cube-join-sql-and-relationship-retired -->` | `check:adr-0087-registration` and `check:migration-registry` are both **exit 0** on this head. Round 3 added three things beyond the registration: - **The artifact at rest heals at the boot door.** All three doors in `packages/metadata/src/plugin.ts` run `_convertArtifactForward` before the strict parse, so a cube persisted with the old `{ name, relationship, sql }` shape is converted rather than refused. Pinned by `analytics.test.ts` — *"a persisted cube heals at the door"* — with its own lit control and the per-cube notice paths. - **`name`'s describe now states the convention it always had**: the join KEY is the foreign-key field on the cube's own object, and the emission is `LEFT JOIN <name> <key> ON <base>.<key> = <key>.id`. - **The showcase join is re-keyed** `showcase_project` → `project`, matching `task.object.ts`'s `Field.masterDetail('showcase_project')`; `gap-fill.test.ts` pins every join key against the base object's real field map rather than against a literal. ## The same measurement also chose the retirement ROUTE The ruling says 「`retiredKey()` tombstones per the standing shape」. `CubeJoinSchema` is a `strictObject`, and for a strict shape AGENTS.md's standing shape is **strict deletion plus a `guidance` prescription**, not a `retiredKey()` tombstone — the route `MetricSchema.filters` took one shape over in this same file (`packages/spec/src/migrations/entries/retired-keys/18.data__Metric__filters.ts` states it in as many words). Measured both ways on this tree: - `retiredKey()` tombstones: `check:authorable-surface` **exit 1** — *"2 key(s) were tombstoned with no registered retirement"*, naming `data/CubeJoin:relationship` and `data/CubeJoin:sql` and demanding those exact lines in `RETIRED_KEYS_BY_MAJOR` (the fenced file). Probe reverted; tree hash restored byte-identical to HEAD. - guidance route: `check:authorable-surface` **exit 0**, adjudicating the two baseline deletions under the objectstack-ai#4650 proof 4 it prints itself — *"2 baseline deletion(s) since 84ba4a8 carry their own proof: data/CubeJoin:relationship — def reachable from the metadata-type roots; writing 'relationship' on it is REFUSED as an unrecognized key"*, and the same for `sql`. - ⏱️ Both readings above were taken by the dev in round 1 and re-taken by the at-tier contract review at head `e177aa2686`; this seat adopted that record at 2026-09-18T14:39Z (comment `5731599385`). ⛔ They are not this seat's own runs. Either route needs the registration; it is now in-diff, in the table above. The route choice is independent of that registration, and is called out here so an at-tier reviewer can overrule it cheaply. ## Acceptance legs, both readings **LIT — an authored ON clause must be refused, in words a JSON author reads** | | `CubeJoinSchema.safeParse({ name: 'other', sql: 'a.id = b.a_id' })` | | --- | --- | | before | **ACCEPTED** — parsed to `{"name":"other","relationship":"many_to_one","sql":"a.id = b.a_id"}` | | after | **REFUSED**, `unrecognized_keys`, message: *"…was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — it never had an effect… Delete the key. A cube join has no authorable ON clause: it is DERIVED from the declared relationship between the two cubes' objects, as a foreign-key equality."* | **DARK — a join that declares only its object must still parse** | | `CubeJoinSchema.safeParse({ name: 'other' })` | | --- | --- | | before | **REFUSED** — `sql` was required (`invalid_type` at path `sql`) | | after | **ACCEPTED** — `{"name":"other"}` | **Alias leg — `{ on: 'x' }`, read once before and once after** | | reading | | --- | --- | | before | ``Unrecognized key(s) on this cube join: `on`. Did you mean `on` → `sql`?`` | | after | ``Unrecognized key(s) on this cube join: `on`.`` followed by the derivation prescription, and **no rename suggestion** | `aliases: { on: 'sql' }` is deleted rather than left pointing at a retired key: an alias naming a key the shape cannot accept answers the author with a second rejection — the `triggerPhrase` failure `packages/spec/src/shared/strict-object.ts` records. `on` now carries its own `guidance` entry, and both directions are pinned. ## The census the ruling took, re-taken — and one correction The ruling recorded 「authored cube `joins` in hotcrm, objectstack examples and cloud — **0 files**」. Re-measured first-hand on this tree, **objectstack is not 0**: - `examples/app-showcase/src/data/analytics/showcase.cube.ts` authors **both** keys, including `sql: '${showcase_delivery}.project = ${showcase_project}.id'` — a live instance of the defect, an ON clause the runtime was silently replacing. Fixed here. - Seven more authoring sites in `packages/services/service-analytics`'s own test fixtures, found by `tsc` after the keys left `z.input`, not by grep. Two of them authored `relationship: 'belongsTo'` — a value the enum never declared, which is its own evidence that nothing validated or read the key. All fixed here. This does not move the ruling: those are in-repo producers, fixed in this same diff, and they are what the retirement checklist calls for. It does mean 「zero producers ⇒ no conversion is owed」 rests on the external census only, and that half was **not** re-measurable from here (hotcrm and cloud are other repositories). Consumer census, with a lit control, on this tree: - reads of a join's `sql` anywhere in source: **0** - reads of a join's `relationship` anywhere in source: **0** (`native-sql-strategy.ts` was checked by name: it does not read either) - lit control, reads of a join's `name`: **8** across `native-sql-strategy.ts`, `objectql-strategy.ts` and `analytics-service.ts` ## What else moved, and why - `packages/services/service-analytics/src/dataset-compiler.ts` **constructed** both keys per join (a constant `'many_to_one'` and a synthesised ON string). The literal now carries `name` alone; `parentAlias`, which existed only to build that string, is gone. No read site changes — `analytics-service.ts:1178` still reads `name` only, exactly as the ruling said. - The liveness ledger rows went **with** the keys (`packages/spec/liveness/analytics_cube.json`), which is the strict-deletion route's disposition and the opposite of the tombstone route's. `analytics_cube` drops 12 `dead` to 10; `state-counts.md` regenerated, README notes cell rewritten to describe the set it now has. - `content/docs/references/data/analytics.mdx` is regenerated, not hand-edited. The `CubeJoin` table is now one row and its description states the derivation — which is the docs half the ruling asked for. - `packages/spec/src/data/analytics-strictness-batchd.test.ts` keeps its batch-D pin that an **undeclared** join key is refused by name; the fixture drops the two now-retired spellings so the pin isolates what it always pinned. Three new pins beside it cover `sql`, `relationship` and `on`. ## Verification Two readings, kept apart on purpose — one is the reviewer's, one is this seat's. **① At-tier contract review, taken at head `e177aa2686`**, adopted by this seat at 2026-09-18T14:39Z (comment `5731599385`), run in its own detached worktree (fresh `pnpm install --frozen-lockfile`, heavy steps under `scripts/pm/os-verify-lock.sh`, exit codes captured before any pipe). All exit 0: spec `build` · `check:generated` (*"All 15 generated artifacts are up to date"*) · `check:authorable-surface` · `check:liveness` · `check:migration-registry` (*"225 semantic, 195 retired-key, 181 retired-def"*) · `check-adr-0087-registration` and `--self-test` · `check-changeset-no-major` · `check:spec-docblock-symbol-anchors` (*"3130 anchors across 1462 spec sources resolve"*) · eslint over the 11 changed source/test files · `@objectstack/spec test` **488 files / 14190 tests** · `@objectstack/service-analytics test` **112 files / 2403 tests** · showcase `gap-fill.test.ts` **13 tests** · typecheck for spec, service-analytics and the showcase · `check:exported-any`, `check:yaml-examples`, `check:dual-source-exports`, `check:entry-nameability`, `check:browser-reachable-entries`, `check:skill-examples`, `check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`. That review — same adoption, ⏱️ 2026-09-18T14:39Z — returned **FAIL on one mechanical blocker and nothing else**, not a judgment defect. The REQUIRED context `TypeScript Type Check` was red at `e177aa2686` because `check:api-surface-declarations` landed on main at `d8b12fca97`, **after** this branch's merge-base, so the branch carried neither the gate nor `packages/spec/api-surface-declarations/`. **② This seat's own reading of the fix, taken from the GitHub API at head `7caf92189a`** (⏱️ 2026-09-18T14:59Z 取): commit `59bd587aea` merges `origin/main`, and `7caf92189a` regenerates the shards. The API reports that commit as `{"total":30,"additions":0,"deletions":30}` over exactly three files — `api-surface-declarations/data.txt` −12, `root.txt` −12, `system.txt` −6. A **pure deletion**: ⛔ not one line was added, so nothing was hand-written into a generated artefact. That is byte-for-byte the shape the review predicted (each reshaped declaration loses `sql: z.ZodString;` and the `relationship` enum block, propagated by type inlining). CI at this head, ⏱️ 2026-09-18T14:59Z 取: **0 failing check runs out of 33**. `Build Core`, `Dogfood Regression Gate`, `Temporal Conformance (live PG + MySQL)` and `Governed Surface Queue Guard` are success; `Lint & Repo Gates` is in progress; `TypeScript Type Check` and `Test Core` have not reported yet. ⛔ Not-yet-reported is **not** passing, and this PR is not landed on that basis. ⛔ Not a complete account of what CI runs here: the 50 artifact-roster families, the 11 declared wide-population families, the 6 path-scheduled CI jobs and the always-runs tail each sit outside any derived total above. Not measured anywhere: repo-wide `pnpm test` / `pnpm typecheck`, `check:dual-build-cjs-loads`, and the external hotcrm / cloud census (other repositories). **⚠️ Landing-order note, so nobody is surprised.** PR objectstack-ai#19024 (the maintainer's, `priority:p1`) reverts objectstack-ai#18971 and **deletes all 17 declaration shards**. Whichever of the two lands second must merge the other first; if objectstack-ai#19024 goes in ahead of this PR, the regeneration commit above becomes moot and its three files disappear with the rest of the snapshot. ⛔ That is a mechanical merge, not a defect in either diff. ## Acceptance notes Noted, not filed — observations, no card: - `packages/spec/liveness/analytics_cube.json` still records `public` as an access-control flag that gates nothing and `refreshKey.every` / `refreshKey.sql` as a caching block with no scheduler. Both are already recorded there with their measurements; ADR-0049 wants a decision on each, and neither is this card. Successor: whoever picks up the `analytics_cube` ledger's remaining `dead` rows. - `AnalyticsQueryRequestSchema` reaches `CubeJoinSchema` only through `CubeSchema`, so no REST request surface changes. Successor: none. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…w arm rules (objectstack-ai#19046) (objectstack-ai#19095) Fixes objectstack-ai#19046 Clause-②: yes The `object-grid` page-component door declared `pagination: z.unknown()` and `pageSize: z.number()`, so the same authored member carried **two accept sets** and renderers read the looser one. This bounds the page-size members to the accept set the view arm has ruled all along, and deliberately leaves the `pagination` bag open. ## The premise, re-derived by symbol at this branch's base (`362035cc0`) ⛔ No line number inherited from the card — triage warned about exactly that, and the card's own reading was taken on `abb01f1`. | arm | symbol | declaration at my base | accepts `0`? | |:--|:--|:--|:--| | view | `PaginationConfigSchema` (`packages/spec/src/ui/view.zod.ts:867-868`) | `pageSize: z.number().int().positive().default(25)` · `pageSizeOptions: z.array(z.number().int().positive()).optional()` | no | | grid component | `ObjectGridPropsSchema` (`packages/spec/src/ui/component.zod.ts:2632`, `:2634`) | `pagination: z.unknown().optional()` · `pageSize: z.number().optional()` | **yes — both** | The view arm's refusals are pinned **by name** (`view.test.ts` — `should reject negative pageSize`, `should reject zero pageSize`, and the same pair for `pageSizeOptions`). The corpus corroboration also holds at my base — every other page-size declaration in the package is bounded: ``` packages/spec/src/ui/view.zod.ts:867 z.number().int().positive().default(25) packages/spec/src/ui/view.zod.ts:868 z.array(z.number().int().positive()) packages/spec/src/ui/component.zod.ts:2634 z.number() ** the outlier packages/spec/src/marketplace/marketplace.zod.ts:435 z.number().int().min(1).max(100).default(20) packages/spec/src/marketplace/marketplace.zod.ts:456 z.number().int().min(1) packages/spec/src/kernel/metadata-plugin.zod.ts:399 z.number().int().min(1).max(500).default(50) packages/spec/src/kernel/metadata-plugin.zod.ts:429 z.number().int().min(1) ``` PR objectstack-ai#18638, which held this file, is merged (2026-09-18T16:01:37Z) and did **not** tighten it in passing, so triage's downgrade clause does not apply. ## The shape decision — a permissive object, and the evidence that chose it The card's complaint is that the two arms disagree about a page **size**. It is ⛔ not that `pagination` should become a closed shape. Two shapes were plausible; the evidence is one-sided. **Chosen: `z.looseObject({ pageSize, pageSizeOptions })`** — validates the two declared members, passes every other key through. **Rejected: `z.unknown()` plus a refinement judging only `pageSize`.** It looks more conservative and is measurably worse here: - `z.toJSONSchema()` has **no arm for a `custom` check**. A record, the same record with a `.refine()`, and the same record with an aborting `.refine()` all project byte-identically — the mechanism `packages/spec/dropped-refinements.baseline.json` exists to record. A refinement would have left the **published** JSON Schema still accepting `pageSize: 0` while the parser refused it, and it would have needed a **new row in that shrink-only ledger**, which is a ratchet this dev may not raise. - The loose object is a **type** narrowing, so it projects. Measured on the built artifact: ``` packages/spec/json-schema/ui/ObjectGridProps.json pagination.properties.pageSize { "type": "integer", "exclusiveMinimum": 0 } pagination.properties.pageSizeOptions.items { "type": "integer", "exclusiveMinimum": 0 } pagination.additionalProperties {} ** the bag stays OPEN pageSize { "type": "integer", "exclusiveMinimum": 0 } ``` `dropped-refinements.baseline.json` is **untouched** by this PR: `ui/ObjectGridProps` keeps its single pre-existing `filter.element` site and gains none. **Read points, measured at objectui `d18322415`** (the sibling checkout in this container; the `.objectui-sha` pin is `53ded82bf`): `ObjectGrid.tsx:1209` and `:1628` read `(schema.pagination as any)?.pageSize ?? schema.pageSize`, `:4179` reads `schema.pagination?.pageSize`, `:4359` reads `schema.pagination?.pageSizeOptions`. Across objectui's whole source, `pageSize` and `pageSizeOptions` are the **only two members** any `pagination` read point names (37 + 6 reads of `.pageSize`, 7 + 3 of `.pageSizeOptions`, zero of anything else). The objectui registry declares this input `type: 'object'` (`plugin-grid/src/index.tsx:223`). ### What was NOT narrowed, and why - **Sibling keys inside the bag.** `z.looseObject`, not `strictObject`: a sibling key that parsed before still parses **and still survives the parse byte-identically**. Reusing `PaginationConfigSchema` here would have refused every one of them — the `…` in this door's own describe says authors write them — which is a wider breaking change than the card's premise and a different decision. §3 of the new pin is what makes that auditable; §4 records the deliberate asymmetry (the view arm stays closed, this bag stays open), so a future author harmonising the two arms reds a case instead of discovering the consequence in a renderer. - **No `.default(25)` added to the flat shorthand.** The view arm has one; adding one here would change parsed output, not the accept set. - **`pageSizeOptions` WAS bounded, and that is a judgement I am naming rather than burying.** It is the same defect class by a second door: `pageSizeOptions: [0, 25]` puts a zero entry in the page-size selector, which sets the fetch window to zero rows — the card's exact failure. Its shape was already pinned by the view arm (`z.array(z.number().int().positive())`), whose zero/negative refusals are pinned by name, and its read point is measured above. Corpus cost: zero `pageSizeOptions` entries outside the spec's own refusal fixtures are non-positive. ### One second axis, stated rather than left to be discovered `pagination` moves from `z.unknown()` to an object type, so a non-object value (`pagination: true`) is refused where it used to parse. Measured before narrowing: - **zero** non-object `pagination` values on an `object-grid` node in either repository (the `pagination: false` hits in objectui are on `data-table` / `object-data-table`, whose props this schema does not declare, plus one internal per-group table the grid builds itself at `ObjectGrid.tsx:4590`); - the registry has published `type: 'object'` all along, so the html tier already answered `type-mismatch` on one while this schema accepted it — the same shape the `sort` docblock two members up already records; - `ObjectGrid.tsx:4175` reads the key for **presence** (`schema.pagination !== undefined ? true : …`), which means an authored `pagination: false` used to turn paging **ON**. That value now gets a located refusal instead of the opposite of what it says. ## Pins, each with its control New file: `packages/spec/src/ui/component-object-grid-pagination-accept-set.pin.test.ts` — 19 cases, 4 sections. | section | asserts | control | |:--|:--|:--| | §1 | `pagination.pageSize` refuses zero / negative / non-integer, and `pageSizeOptions` entries refuse zero / negative — each asserting the issue **code and path** (`too_small` at `pagination.pageSize`), not a bare throw | two LIT CONTROLS: a legal `pageSize` parses and is preserved; the whole ruled bag parses with its options | | §2 | the flat shorthand carries the same accept set, by name | a LIT CONTROL: `pageSize: 25` parses and keeps its value | | §3 | a sibling key in the bag parses with **no `unrecognized_keys` issue**, survives byte-identically (`toStrictEqual`), and a bag of only sibling keys parses | this section IS the control for the trap above | | §4 | both arms refuse the same three non-page-sizes, and both accept `50` | an unknown KEY is refused by the view arm (`unrecognized_keys`) and accepted by the component bag — the asymmetry, pinned | **Defect reproduced in this tree, then the refusal proved able to fail.** Ablation through `scripts/ablation-replace.mjs`, anchor `const GridPageSizeSchema = z.number().int().positive();` replaced by `const GridPageSizeSchema = z.number();` (the pre-PR accept set), from the committed state: ``` ablation-replace: ok mutation landed: anchor 1 -> 0, blob d9e4dec -> 462c333a1bda Test Files 1 failed (1) Tests 11 failed | 8 passed (19) FAIL §1 ... > should reject zero pageSize AssertionError: expected true to be false ** parse({ pagination: { pageSize: 0 } }) SUCCEEDS ablation-replace: ok restored: blob == HEAD (d9e4dec) and `git diff HEAD` is empty ``` The 11 that reddened are exactly §1/§2/§4's refusals; the 8 that stayed green are the lit controls and §3's openness pins — the right partition, since the ablation removed only the value bound. Restored again through the explicit form: `git checkout HEAD -- packages/spec/src/ui/component.zod.ts`, then `git hash-object` equal to `git rev-parse HEAD:` that path (`d9e4decd6443…`), `git diff HEAD` empty and `git status --porcelain` empty — and the pin re-run green (19/19) from the restored tree. ## Changeset — the derivation, quoting the rule `.changeset/19046-object-grid-page-size-accept-set.md` grades `@objectstack/spec: minor`, carries the BREAKING banner, `Clause-②: yes (narrowing)`, a FROM → TO table and the ADR-0087 disposition. - `scripts/check-changeset-no-major.mjs` header: **"During the launch window we ship breaking changes as `minor`"**, and its end condition — **"at GA … an accept-set narrowing … grades `major`. Until then it is NOT the carrier"** — with `major` refused outright by the guard. So the rule does ⛔ not point at `major`, and there is nothing here for the maintainer floor to rule on. - `pr-automation.yml` "WHICH LEVEL": a widening takes at least `minor`, and the level axis refuses `patch` across the board on a PR that declares clause ②. Declaring `Clause-②: yes` therefore forces at least `minor` — which is where the launch-window rule already put it. - Direction carriers, per the same header: the **BREAKING banner** plus the **ADR-0087 disposition**. Disposition is `registered ui-object-grid-page-size-positive-integer-refused`, a new semantic entry — the four `not-required` categories are all refused by construction here (`unpublished`: spec publishes; `no-migration-prescription` and `runtime-interface-only`: the body carries a FROM → TO table, and "a changeset that ships instructions for rewriting a consumer's code cannot also claim that no consumer has to rewrite anything"; `type-surface-only`: this is a runtime accept set on a metadata surface, not a type annotation). - `skip-changeset` was never available: this moves a published accept set on a package that ships. Verdicts: `check-changeset-no-major.mjs` exit **0**; `check-adr-0087-registration.mjs` exit **0** — `1 declared-breaking changeset(s), each carrying an ADR-0087 disposition`. ## Verification Full census derived from the real change set after the changeset existed, at `8ecc9b6ed`, with every exit code captured **before** any pipe: ``` node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack -> 8 path(s) vs merge base 07c6f82, three-dot; 109 commands 107 exit 0 · 2 PREREQUISITE NOT MET (exit 3) · 0 findings ``` The two that could not run, neither a pass nor a finding: | family | reason | what it needs | |:--|:--|:--| | `pnpm check:dual-build-cjs-loads` | `PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/` (34 packages) | a repo-wide `pnpm build`; CI's `Build Core` supplies it | | `pnpm check:type-check-debt` | `--re-measure cannot run: 1 workspace dependenc(ies) … have no built type entry point on disk — @objectstack/driver-turso` | `turbo run build --filter='./packages/*' --filter='./packages/*/*'`, as lint.yml does | Four families reported `PREREQUISITE NOT MET` or a missing input on first run and were then **made to run** rather than declared: `check:doc-formula-expressions` and `check:doc-security-posture` (needed `@objectstack/formula` + `@objectstack/lint` built) and `check:skill-examples` (needed `@objectstack/client-react`'s closure) all became exit 0; `check:react-declaration-parity` was run as CI runs it (`MANIFEST="$PWD/sdui.manifest.json" … --strict`) and reports **no new declaration divergence vs the accepted baseline**. Beyond the census: - `pnpm --filter @objectstack/spec test` — **495 files / 14539 tests pass** (post-merge); `typecheck` green, test-layer ledger unmoved at 54 files / 259 errors / 144 pinned signatures. - `pnpm --filter @objectstack/spec check:generated` — **all 16 generated artifacts up to date**. Three were proved stale and regenerated with `--fix` only (`api-surface-declarations/`, `content/docs/references/**`, the strictness-ledger counts); the `authorable-surface.base.json` anchor was never touched. - The one in-repo consumer of the changed surface is `packages/lint` (`ComponentPropsMap`, `@objectstack/spec/ui`): `typecheck` green with its ledger unmoved (2 files / 6 errors / 2 pinned), `test` **104 files / 3910 tests pass**. No corpus fixture anywhere in `examples/`, `apps/` or another package authors `pagination` on an `object-grid` node, so nothing in the tree newly fails to parse. - Repo-wide `pnpm lint` (`eslint . --no-inline-config`) — exit **0**, whole tree, no narrowing claimed. - `pnpm check:nul-bytes` exit 0, plus a direct control-character scan over all 8 changed paths — clean. - Merged `origin/main` through `scripts/pm/os-regen-merge.sh` (its step 2 took main's side of `api-surface-declarations/ui.txt`, which both sides moved, and step 3's hook held the regeneration debt until it was discharged). This branch's delta against `origin/main` on that shard is now **exactly the two `pagination` hunks**, with main's own advance intact. ### The widening-tells reading, with its caveat ``` node scripts/pm/check-widening-tells.mjs --declaration yes --diff PRDIFF -> exit 0 ✓ the claim declares `Clause-②: yes`, which this gate never blocks — a `yes` already routes to contract review, so a tell on top of it decides nothing. ```⚠️ **That exit 0 is the absence of a reading, not a clean one.** With `yes` the gate short-circuits and examines **no file**. Run as a **diagnostic only** with `--declaration no`, it exits 4 on two T1 tells: `component.zod.ts:2689` (`pageSizeOptions`) and `:2692` (`pageSize`) — *"a new key on a Zod object schema"*. Textually right, semantically inverted for this diff: both members were already writable through `z.unknown()`, which accepted everything; what the diff does is **bound** them. That is a limitation of the matcher, not a signal about this PR, and it is in the acceptance notes below rather than repaired here. ## Acceptance notes *The two paragraphs below were added by the `domain:spec#3` seat after the body's single dev write, on the dev's own hand-over; ⛔ a dev writes a PR body once, at creation.* **The migration registry, with four open PRs adding entries to it.** Mine, objectstack-ai#19090, objectstack-ai#19084 and objectstack-ai#18319 each add one semantic entry. Identity cannot collide silently: the entry id IS the identity and the **filename is a function of it**, so a duplicate would be a loud git add/add conflict — the generator says so in as many words, and the four ids are four distinct files. Order is **derived** `(major, id)` from the directory listing, with no index file and no positional consumer (`migrations/chain.ts` keys by MAJOR, `MIGRATIONS_BY_MAJOR[m]`), so a clean text merge cannot express a wrong *meaning* — the `18.` prefix is the protocol-major bucket, not a sequence number. The gate is `pnpm --filter @objectstack/spec check:migration-registry`, run at exit 0 (「229 semantic, 195 retired-key, 181 retired-def」 current): it proves the emitted regions equal what the entries directory says, so a merge that dropped one side reds and one that kept both out of order reds too. Adjacency measured over the 141 existing `18.*` entries plus the four in flight: **7 / 49 / 61** existing entries lie between mine and objectstack-ai#19090 / objectstack-ai#19084 / objectstack-ai#18319 — no pair is adjacent, and the register's own insertion-only property then predicts a clean, current union whatever the landing order.⚠️ And `registry.ts` is deliberately **NOT** in the `merge=os-regen` register (classified MIXED, 「a deferral would launder the prose」), so a conflict there is **loud and a human's** — the silent-drop class does not reach it. **The hand-written docs negative, recorded so it is not reopened.** Probe: hand-written `content/docs` trees (excluding `references/` and `releases/`) authoring a `pageSize` value this narrowing refuses (`0`, negative, decimal) → **ZERO**. **Lit control, same instrument:** it does find authored `pageSize` occurrences — `content/docs/api/data-api.mdx:42` (`?pageSize=5`) and `content/docs/api/error-catalog.mdx:151` — over 2 hand-written pages and 9 pages including the generated tree, so the zero is a reading rather than a dead grep. **Attribution, which is the part that matters:** neither control hit is this door's `pagination.pageSize` — `data-api.mdx` documents `pageSize` as an *unknown REST query parameter* refused in favour of `top` / `$top` / `limit`, and the remaining pages are the metadata response shape, the object page and the metadata-plugin page. Four different `pageSize` members, none of them this one. ⇒ nothing owed on the hand-written side; the generated `content/docs/references/ui/component.mdx` already moved in this diff. The attribution step is the prescription of **objectstack-ai#19093**, filed today after a name-based hit produced a false stop-the-line alarm on a sibling PR. Observations found in passing. ⛔ None is filed as a card by this PR, and none is in its scope. - **The widening-tells matcher cannot tell a narrowing-inside-a-bag from a widening.** A PR that honestly declares `Clause-②: no (narrowing)` — a legal, precedented declaration (`.changeset/17499-groupbyfield-non-padded.md` carries exactly it) — and bounds a member inside a previously-`z.unknown()` bag is blocked at exit 4 by a T1 tell that names the bound as a widening, because the matcher reads the added key text and not the member's prior schema. Reproduced on this diff, above. The honest declaration is the blocked one. The successor: the next accept-set narrowing on this board. Dedupe words: `widening-tells T1 narrowing inside z.unknown bag`, `check-widening-tells false tell narrowing`, `clause-2 no narrowing blocked exit 4`. - **`frozenColumns: z.number().optional()`** on this same door (`component.zod.ts`) is unbounded, and the renderer reads it as a leading-column count. ⛔ Not filed and ⛔ not touched: no repro, no measured consumer breakage, and it is not this card's member. Noted, not filed. The successor is any future PR on this door's numeric members. - **`pagination: false` / `pagination: true` on `data-table` / `object-data-table`** is authored in objectui and those props are not declared in `ComponentPropsMap` at all, so nothing in this repo judges them. Noted, not filed; that is the sibling repo's declaration surface, not this door's. ## Notes for the reviewer - ⛔ This PR does **not** hang, clear or touch `needs:contract-review`, and writes **no label** — both carriers are the seat's write. `Clause-②: yes` is here because triage ruled it; ⛔ this author does not review its own clause-② verdict. - `packages/spec/api-surface-declarations/ui.txt` moved because the declaration text moved. PR objectstack-ai#19024 removes all 17 of those shards; a deletion-versus-modification conflict there resolves in favour of the deletion and is expected — ⛔ not pre-solved here. - No governed surface is in the diff (checked against `GOVERNED_SURFACES` in `scripts/pm/check-governed-merges.mjs`): `docs/audits/` is not `docs/adr/`. - objectui#9853 is the consumer half's card and objectui#9896 its landed repair; this is the declaration half and was never a prerequisite for it. objectstack#18972 names this same class on the declaration side, and objectstack-ai#19083 landed its `scale` instance three commits before this branch's merge base. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…ease pair it really spans (objectstack-ai#19115) Fixes objectstack-ai#18978 Clause-②: yes (widening) — one new OPTIONAL key on a published artifact (`aggregate.surfaceScope`) and one new optional field on `SpecChangesSchema`. Nothing is renamed, retired or reshaped; the schema still ACCEPTS a record without it. Contract-review tier. `spec-changes.json`'s `aggregate.added` / `aggregate.removed` are filled by a release-time api-surface diff of the artifact being published against the previously **published** one, so they span **one release** — under a record keyed by protocol major (`from: 10, to: 17`), with every entry carrying only `since: 17` / `removedIn: 17` and `perMajor[16 → 17].added` sitting at `0` beside it. Nothing in the file distinguished one minor's slice from the whole major-boundary delta. --- ## 1 · The defect, re-measured on a real published artifact Instrument: `curl` the Release asset through the REST API, then recompute the delta with a **hand-written flattener in Python** (not this repo's code) over the two published tarballs' own `api-surface/` shard directories. | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **Release asset** `aggregate.added` | **225**, `since` counter `{17: 225}` | | same asset, `aggregate.removed` | **51**, `removedIn` counter `{17: 51}` | | same asset, `aggregate.from` / `aggregate.to` | `10` / `17` | | same asset, `perMajor[16 → 17]` | `added: 0, removed: 0` (converted 57, migrated 77) | | same asset, `release` section | **absent** (generated 2026-09-09, before objectstack-ai#18889) | | independent recompute, `npm pack` 17.3.0 vs 17.4.0 `api-surface/` | **added 225, removed 51** | | set equality, asset arrays vs recompute | `added` **True**, `removed` **True**; 0 only-in-asset, 0 only-in-recompute, both directions, both arrays | So the published arrays are, byte for byte, the **17.3.0 → 17.4.0** one-minor delta, wearing a `10 → 17` label. Cross-check: PR objectstack-ai#17080's own changeset states the same pair as "gained 225 exports and lost 51". ### One refinement to the card's premise, stated because it moves a date, not a verdict | reading | value | |:--|:--| | `@objectstack/spec@17.4.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | `@objectstack/spec@17.3.0` **npm tarball** `aggregate.added` / `removed` | `0` / `0`; no `release` section | | npm publish time of 17.4.0 | `2026-09-09T03:57:51.929Z` | | merge time of objectstack-ai#18889 (`8b4890343`) | `2026-09-18T11:16:13+00:00` | ⇒ **no published tarball carries the mislabelled arrays yet.** The lane that will is on `origin/main` today: `release.yml` runs `release-spec-changes.sh --prepare` (line 1251) and `--verify` (1260) **before** the publish, then `--attach` (1355), and `--prepare` invokes the generator with `--previous-package`. The card's "reach is new" premise therefore holds as a property of the lane, and the first tarball to carry it is the next publish. Today's carrier is the Release-page asset, measured above. This is a sharpening, not a disproof — nothing in the card's argument depends on a tarball already existing. --- ## 2 · The A/B legs, re-taken Base: `origin/main` at `07c6f822e`. Previous artifact: `npm pack @objectstack/spec@17.3.0`, unpacked. Both legs write the real snapshot path, so each was copied out and the tree restored by `git checkout HEAD -- packages/spec/spec-changes.json` with the blob hash re-read each time (`9dbc98682…` in, `9dbc98682…` out, `git diff HEAD` empty, `git status --porcelain` empty). | leg | what ran | result | |:--|:--|:--| | **A** | HEAD generator, `--previous-package PKG_DIR` | `aggregate.added` **399** (`since` counter `{17: 399}`), `aggregate.removed` **302** (`removedIn` counter `{17: 302}`), `perMajor[16 → 17]` **0 / 0**, `release` 17.3.0 → 17.4.0 with 399 / 302 | | **B** | generator at `43f4766889e` — objectstack-ai#18889's parent, verified **0** occurrences of the string `--previous-package` on disk and 3 of `--previous-surface` — invoked with `--previous-surface` | `aggregate`, `perMajor`, `protocolVersion`, `supportFloor`, `migrateCommand` all **canonical-hash identical to leg A** (`aggregate` = `d8c3e5c4303c2ecc` on both) | Whole-document diff between the two legs: the `release` key (leg A only) and `$comment` (which objectstack-ai#18889 extended). Nothing else. ⇒ **the computation is pre-existing**, exactly as the card claimed. One reading the card did not state, and it is the sharpest one: in leg A, `aggregate.added` / `aggregate.removed` are **set-identical to `release.added` / `release.removed`**. The aggregate record does not merely resemble a one-release slice — it *is* the release slice, under a major-resolution header. --- ## 3 · ⭐ The consumer survey the card named as unmeasured **Question:** who reads `aggregate.added` / `aggregate.removed` today? ### Radius, declared | # | in radius | how read | |:--|:--|:--| | R1 | `objectstack-ai/objectstack` @ `origin/main` `07c6f822e` | `git grep -I` over tracked **and** untracked files, whole tree, no `head` anywhere | | R2 | `objectstack-ai/objectui` @ `origin/main` `05a49f2ee` (fetched for this survey) | `git grep -lI PATTERN origin/main` | | R3 | the published tarball's own contents | `npm pack` 17.3.0 and 17.4.0, plus `packages/spec/package.json` `files[]` | | R4 | the documented / prescribed consumers | `content/docs/upgrading.mdx`, `skills/objectstack-upgrade/SKILL.md` (the **published** skill catalog), `docs/adr/0087` | **Outside the radius, named as outside it:** the `objectstack-ai/cloud` repository (not checked out in this container); any third-party or private consumer of the npm artifact or of the Release-page asset; and the `spec_changes` MCP tool, which is **prose only** — `git grep spec_changes` over R1 returns docs, ADRs, changelogs and code comments and **zero implementation**, so there is nothing there to read anything. ### Instrument, in two stages - **Stage 1 — population.** Every site naming the literal `spec-changes.json`, plus every site naming a key that is distinctive to this manifest (`perMajor`, `supportFloor`). Enumerable and small; each hit was then read. - **Stage 2 — field classification.** For each member of that population, which top-level keys it actually reads. ### ⭐ Lit controls, so a zero is a reading | control | instrument | result | |:--|:--|:--| | L1 · a site that provably reads a field of `spec-changes.json`, found by stage 1 | `git grep -n "spec-changes\.json"` | **found** `packages/cli/src/utils/spec-release-changes.ts:80`, which reads `doc.release` at line 106 — a real, shipping reader | | L2 · the distinctive-key instrument is not dead | `git grep -n perMajor` / `supportFloor` | **found** the producer, two gate fixtures, and the published `skills/objectstack-upgrade/SKILL.md:219` + its `node -e` snippet at 229-236 | | L3 · the instrument reaches objectui at all | `git grep -lI PATTERN origin/main` in `../objectui` | `@objectstack/spec` → **1628** files; `api-surface` (another published spec artifact) → **3** files | ### Result | consumer | radius | reads | reads `aggregate.added` / `removed`? | |:--|:--|:--|:--| | `packages/cli/src/utils/spec-release-changes.ts:106` (ships in `@objectstack/cli`) | R1 / R3 | `doc.release` and the lengths of its four arrays | **no** | | `scripts/check-release-spec-changes.mjs` `aggregateIds()` | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId`, `release.*` | **no** | | `packages/spec/scripts/build-spec-changes.ts` `previousRelease()` (reads the PREVIOUS tarball) | R1 | `aggregate.converted[].conversionId`, `aggregate.migrated[].migrationId` | **no** | | `scripts/check-adr-0087-registration.mjs` (parser-rot witness) | R1 | `migrationId` occurrences | **no** | | `skills/objectstack-upgrade/SKILL.md` — **published** to customer projects | R4 | `perMajor[].converted`, `perMajor[].migrated`, `protocolVersion`, `supportFloor` | **no** | | `content/docs/upgrading.mdx` | R4 | `.release.*`; and, for withdrawals only, `.aggregate.converted[].conversionId` / `.aggregate.migrated[].migrationId` | **no** | | whole `objectui` repository | R2 | nothing — `spec-changes` → **0** files, `perMajor` → 0, `supportFloor` → 0, `spec_changes` → 0 | **no** | | `spec_changes` MCP tool | R1 / R4 | does not exist as code | n/a | | `scripts/regen-artifacts.mjs`, `check-regen-pending.mjs`, `objectui-changeset-digest.mjs`, `check-published-files.mjs`, `docs-audit/affected-docs.mjs` | R1 | the **path**, as a ledger row — never a field | **no** | ⇒ **Zero readers of `aggregate.added` / `aggregate.removed` in the reachable radius.** Every field-level reader of `aggregate` reads `converted` / `migrated` only. Confirming probes: `git grep -nE "aggregate(\.|\[[\"'])(added|removed)"` over R1 returns **0 rows**; the loosened, case-insensitive variant returns 11 rows, all the English phrase "aggregate added to the spec" about SQL aggregate functions. **But there is a declared contract, and it is the one the defect breaks.** `content/docs/upgrading.mdx:338` says, of this very field: "The same file's `aggregate` and `perMajor` records are unchanged and still answer the **major-boundary question**." They do not. That sentence is the class-(b) contract text — a machine-readable surface that does not say what it means — and it is what makes this a defect rather than an unused field. ### Why the survey licenses the shape taken The dispatch allows two shapes: **gate** the aggregate arrays as `release.*` is gated, or **relabel** them at the resolution they actually carry. Gate-only cannot be the whole fix here, and that is a measurement, not a preference: there is no computable "correct" 10 → 17 export delta to gate against, because tarballs before protocol 15 ship no `api-surface` snapshot at all. A gate that merely refused today's shape would wedge every release until the producer changed — and the producer changing **is** the relabel. So the gate is not an alternative to the relabel; it is the **negative control for it**. And with **zero** readers, relabelling is free: nothing downstream can break, so the honest fix is available at no migration cost. That is what the survey buys. ⛔ **Not taken, and reported instead:** removing the fields, or ceasing to emit them. The survey lands exactly where the card guessed it might — nobody reads them — so the removal question is live, and it is the maintainer's. See `## Acceptance notes`. --- ## 4 · What changed A record whose export arrays are non-empty now carries the version pair they were diffed between: ```json "aggregate": { "from": 10, "to": 17, "surfaceScope": { "fromVersion": "17.3.0", "toVersion": "17.4.0" }, "added": [], "removed": [] } ``` - **`packages/spec/src/migrations/spec-changes.ts`** — `SpecSurfaceScopeSchema` + `SpecSurfaceScope`, an optional `surfaceScope` on `SpecChangesSchema`, `SurfaceDiff.scope`, and `surfaceScopeProblem(record)`, which is the refusal. The record spreads the key in rather than assigning `undefined`, so a record with no export diff serialises exactly as before. - **`packages/spec/scripts/build-spec-changes.ts`** — reads the previous version off the previous artifact's own `package.json` (`--previous-package PKG_DIR`, or the sibling of a `--previous-surface` snapshot), OMITS the arrays loudly when it cannot, and refuses outright to write a non-empty unlabelled array. - **`scripts/check-release-spec-changes.mjs`** — `verifyAggregateSurface()` recomputes the aggregate's claim from the same two tarballs the release section is checked against, and refuses an absent, mislabelled or untrue scope in both directions. Self-test roster **15 → 23** batteries. The failure headline now names which claim disagreed. - **`packages/spec/src/migrations/spec-changes-surface-scope.test.ts`** — new. - Regenerated: `packages/spec/spec-changes.json` (one line — its `$comment`) and `packages/spec/api-surface-declarations/root.txt` (+6 / -0). ⛔ **Not narrowed on purpose.** `SpecChangesSchema` still accepts an unscoped diff, because every manifest published so far carries one and a schema that refused them would narrow what an already-shipped artifact parses as. The refusal lives at the producer and at the publish gate. ⛔ **`packages/spec/src/migrations/registry.ts` was not touched** (held by objectstack-ai#19095, objectstack-ai#19090, objectstack-ai#19084, objectstack-ai#18319). The change is additive, so it declares no ADR-0087 disposition and needs no migration entry: `node scripts/check-adr-0087-registration.mjs --base origin/main` → "this PR adds no declared-breaking changeset". `scripts/regen-artifacts.mjs` (held by objectstack-ai#19024) and `content/docs/releases/**` were not touched either. The public entry barrel `packages/spec/src/migrations/index.ts` was deliberately left alone, which is why `check:api-surface` is green with no export-name churn. --- ## 5 · ⭐ Acceptance controls ### Control 1 — a test that fails on today's composition (acceptance 1) Ablation via `node scripts/ablation-replace.mjs`, which proves the mutation reached disk before running anything: ```text ablation-replace: anchor "...(surfaceDiff.scope ? { surfaceScope: surfaceDiff.scope } : {})," x1 (before) ablation-replace: anchor x1 -> x0 ablation-replace: replace "// ABLATION: the composer drops the scope..." x0 -> x1 ablation-replace: blob 2e046d0 -> d0c1189d0f233a8d46b2641812713acf33a50181 ablation-replace: ok mutation landed: anchor 1 -> 0, blob 2e046d0 -> d0c1189d0f23 VITEST_EXIT=1 Test Files 1 failed (1) Tests 2 failed | 5 passed (7) ablation-replace: blob after restore 2e046d0 ablation-replace: blob at HEAD 2e046d0 ablation-replace: ok restored: blob == HEAD (2e046d0) and `git diff HEAD` is empty ``` The reported failure is the real one: `expected 'the 10 → 17 record carries 2 added and 1 removed export(s) with no surfaceScope…' to be null`. Unablated: **7 / 7 pass**. No ablation artefact remains — restore proved by blob equality with `HEAD` and an empty `git diff HEAD`, not by an exit code.⚠️ Reported honestly: the first run of this ablation piped vitest into `tail`, so the wrapper printed `command exited 0` while the suite had failed. The run above redirects first and captures `$?` before any pipe. Only the second reading is cited. ### Control 2 — ⭐ preserved truth (acceptance 2), shown rather than asserted Same generator invocation, same real 17.3.0 tarball, before the fix and after; every record compared by canonical JSON: ```text perMajor identical=True protocolVersion identical=True supportFloor identical=True migrateCommand identical=True release identical=True aggregate MINUS surfaceScope identical=True (the only added key: {'fromVersion': '17.3.0', 'toVersion': '17.4.0'}) $comment identical=False (documents the new key) ``` And on the **committed** artifact, per-key against `HEAD`: `aggregate` unchanged, `perMajor` unchanged, `protocolVersion` unchanged, `supportFloor` unchanged, `migrateCommand` unchanged, `$comment` changed — a one-line diff (`1 insertion, 1 deletion`). The per-release section objectstack-ai#18889 added is untouched in both readings, and `composeReleaseChanges` still returns exactly its six keys (pinned in the new test). Two further preserved-truth readings: all **15** pre-existing gate self-test batteries still pass unchanged, and `pnpm --filter @objectstack/spec check:generated` reports "All 16 generated artifacts are up to date". ### Control 3 — ⭐ a negative control that distinguishes fixed from switched off (acceptance 3) The gate run against four constructed publish trees, each carrying the real committed `api-surface/` and a real `package.json`, with the real unpacked 17.3.0 tarball as `--previous`: | input | what it is | gate | |:--|:--|:--| | `good` | the post-fix generator's own output | **EXIT=0** — "release 17.3.0 → 17.4.0 verified … 399 added, 302 removed … aggregate export diff 17.3.0 → 17.4.0 verified: 399 added, 302 removed." | | `bad-prefix` | the **genuine, unmodified pre-fix artifact** — what `main`'s generator produces today | **EXIT=1** — "aggregate.surfaceScope is absent while aggregate.added/removed carry 701 export(s). … Expected { fromVersion: "17.3.0", toVersion: "17.4.0" }." | | `bad-unscoped` | post-fix output with `surfaceScope` deleted | **EXIT=1**, same refusal | | `bad-wrongscope` | `surfaceScope.fromVersion` set to `17.2.0` | **EXIT=1** — "the export diff was taken against a different release." | The `bad-prefix` row is the load-bearing one: the new gate refuses the artifact today's code actually produces, so it is a check that can still fail rather than one that was switched off. Eight further refusals are pinned as self-test batteries (absent scope, wrong `fromVersion`, wrong `toVersion`, an invented export, an omitted real removal, a claim the previous tarball could not have produced), each alongside two GREEN batteries — a matching scoped claim, and the unscoped-empty registry-only projection that must stay accepted. The producer half, both directions: ```text $ tsx scripts/build-spec-changes.ts --previous-surface ORPHAN_DIR/api-surface No aggregate export diff: the previous artifact at ORPHAN_DIR/api-surface carries no readable package.json, so the version pair the diff spans cannot be read. Omitting `added`/`removed` — an unlabelled one-release slice under the major-keyed aggregate record reads as the whole from → to delta. -> aggregate added 0 removed 0 surfaceScope None $ tsx scripts/build-spec-changes.ts --previous-surface PREV_PKG/api-surface -> aggregate added 399 removed 302 surfaceScope {'fromVersion': '17.3.0', 'toVersion': '17.4.0'} ``` ### Control 4 — the card's own numbers, re-measured after the change (acceptance 4) Instrument: HEAD generator, `--previous-package` pointed at the unpacked published 17.3.0 tarball; counters computed by `collections.Counter` over the emitted JSON. | reading | post-fix value | |:--|:--| | `aggregate.from` / `to` | `10` / `17` (unchanged — it still answers the major question for `converted` / `migrated`) | | `aggregate.added` | 399, `since` counter `{17: 399}` | | `aggregate.removed` | 302, `removedIn` counter `{17: 302}` | | `aggregate.surfaceScope` | `{fromVersion: 17.3.0, toVersion: 17.4.0}` ← **new; this is the fix** | | `perMajor[16 → 17]` | `added: 0, removed: 0` (unchanged, and now honest by construction: the record says nothing about exports) | | `release` | 17.3.0 → 17.4.0, 399 added / 302 removed (unchanged) | --- ## 6 · Verification | what | result | |:--|:--| | `pnpm --filter @objectstack/spec build` (forced fresh, under the shared verify lock) | `VERDICT command-exit 0`; `check-dts-emitted: 34/34` | | `pnpm --filter @objectstack/spec typecheck && … test` (under the lock) | `VERDICT command-exit 0` — **495 test files, 14527 tests, all passing** | | `node scripts/check-release-spec-changes.mjs --self-test` | EXIT=0 — **23 batteries pass** (15 pre-existing + 8 new) | | `pnpm --filter @objectstack/spec check:generated` | EXIT=0 — all 16 artifacts up to date | | `pnpm lint` (full repo union, at final commit `0c548868c`) | EXIT=0 — **6878 files linted, 0 errors, 0 warnings** (`--format json` counts) | | `pnpm check:nul-bytes` | EXIT=0 — 8952 text files, no raw control bytes | | `@objectstack/cli` unit tier, `src/utils/spec-release-changes.test.ts` | 6/6 pass — the one downstream reader of this artifact | | gate families derived from the diff (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`) | 103 commands over 7 paths; **43 run green** locally, listed in the report | | `pnpm check:type-check-debt` | **EXIT=3 · PREREQUISITE NOT MET — NOT MEASURED**: `--re-measure` needs the whole workspace build closure on disk and only `packages/spec` was built. Its own words: "This is NOT a pass and NOT a finding". Its non-re-measure invariants reported 0 findings on all three layers. Left to CI, which builds the closure first. | Downstream reach, with a lit control: `git grep -nE "\b(SpecChangesSchema|SurfaceDiff|SpecSurfaceAdd|SpecSurfaceRemove)\b"` outside `packages/spec` returns **0 rows**; the same instrument finds `composeMigrationChain` (a sibling export of the same module directory) in `packages/cli/src/commands/migrate/meta.ts`. ⇒ no package outside `packages/spec` names any changed declaration, so no other package's tests are owed. Export **names** are unchanged (`check:api-surface` green); only declaration text moved (`api-surface-declarations`, +6 / -0). --- ## Acceptance notes 1. ⭐ **The removal question is live, and it is the maintainer's.** The survey found **zero** readers of `aggregate.added` / `aggregate.removed` in the whole reachable radius. The card itself floats "if the answer is nobody, the cheapest honest fix may be to stop emitting it". It was ⛔ **not** implemented here — removing a published machine-readable capability is a maintainer decision — and this PR makes the surface honest instead, which is strictly compatible with a later removal. Recorded as an open question. 2. **`content/docs/upgrading.mdx` is corrected here, not merely reported.** Line 338 said 'The same file's `aggregate` and `perMajor` records are unchanged and still answer the major-boundary question'. That is true of `perMajor`, and of `aggregate.converted` / `aggregate.migrated`, which are registry-derived across the whole range — and it was never true of `aggregate.added` / `aggregate.removed`. The page was the declared contract this artefact did not keep, so correcting it is the doc half of this defect rather than opportunistic cleanup. The path was measured FREE of open-PR holders first (32 open PRs, 364 file rows, instrument lit by all four holders of the migrations registry). 3. **A deliberate boundary in the new gate, so nobody reads it as an oversight.** It refuses a *wrong* aggregate claim; it does not *require* the published artifact to make one. An aggregate with empty arrays and no `surfaceScope` is accepted, because that is the honest registry-only projection. Turning "must not lie" into "must speak" would be a new publish requirement, and that call is not this gate's. The residual hole is narrow: a bug that silently emptied `aggregate.added` while `release.added` stayed correct would pass. Worth a card if the maintainer wants the stronger rule. 4. **`--previous-surface` has no caller left in the repository.** `git grep -- "--previous-surface"` finds only the generator's own argv parsing and its docblock; every lane uses `--previous-package` (`scripts/release-spec-changes.sh:87`). It was kept working — and taught to derive its scope from the snapshot's sibling `package.json` — rather than retired, because retiring a flag is not this card. 5. **`cut-rc.yml` attaches, and never prepares.** It calls `bash scripts/release-spec-changes.sh` with no mode, which defaults to `--attach`, so the RC lane uploads the committed registry-only manifest and never runs `--verify`. Not a defect (the committed copy claims nothing), and not this card — noted because it is the one lane the new gate never sees. 6. **No label was applied by this PR.** `Clause-②: yes` means it and objectstack-ai#18978 owe `needs:contract-review`; that label is the seat's to apply and ⛔ never this branch's to clear. 7. **The two regenerated artefacts were written by the repo's own generators, never by hand.** `packages/spec/spec-changes.json` by `pnpm --filter @objectstack/spec gen:spec-changes`; `packages/spec/api-surface-declarations/root.txt` by `pnpm --filter @objectstack/spec gen:api-surface-declarations`. Both were named stale by `pnpm --filter @objectstack/spec check:generated` first, and only those two were regenerated (`--fix` is deliberately narrow). No `origin/main` merge was performed on this branch, so the `merge=os-regen` silent-resolution hazard on that path was never entered. 8. **The docs-drift bot's three hand-written rows, answered.** `content/docs/api/client-sdk.mdx` and `content/docs/kernel/contracts/metadata-service.mdx` are **still accurate**: both were anchored by a NAME COLLISION on the generic identifiers `fromVersion` / `toVersion` between this PR's new published-version STRINGS and the REST metadata-history routes' INTEGER version parameters (`rest-server.ts:8209` reads `body.toVersion` for `POST /meta/:type/:name/rollback`; `client-sdk.mdx:229-230` spells the SDK keys `from` / `to`; `metadata-service.mdx:87` declares `version: number`). Neither page mentions `spec-changes` at all. `content/docs/upgrading.mdx` is the one genuinely-mine row and is corrected in this PR. ⛔ `content/docs/releases/v17/17-1.mdx` is release-owned and was not edited — it is also **not wrong**: the same collision put it there, its only mention of the route is line 294 in a security context, and it never names `spec-changes`. 9. **The bot's own blind spot, answered by reading rather than by trusting its run.** It declared that `api-surface-declarations/root.txt` and `spec-changes.json` yielded no anchor, so pages documenting those are outside its run — and `spec-changes.json` is this card's subject. A full read of `content/docs/**`, `docs/**` and `skills/**` finds **exactly one** page stating a claim about the aggregate export arrays' resolution: `upgrading.mdx:338`, corrected here. The published `skills/objectstack-upgrade/SKILL.md` points only at `perMajor[].converted` / `perMajor[].migrated` / `protocolVersion` / `supportFloor` — all unaffected and all still true. `content/docs/releases/v15.mdx:521-523` claims only that the file is generated, ships and attaches: still accurate. `docs/adr/0087:210-213` states no falsehood (its 'compose' claim is about the registry-derived arrays), though it is where the ambiguity originates — a governed-surface question, left to the maintainer. <sub>⚠️ Notes 2, 7, 8 and 9 were written into this body by the dispatching seat (`Seat: domain:spec#3`, `session_019srGWGCBBCBHqcDoRZpQRh`) at 2026-09-18T21:06Z, from the implementing dev's final report. The dev correctly refused to PATCH this body: `.claude/agents/os-dev.md:56` says the PR body is written once, on the call that opens the PR, and later corrections are named in the report for the seat to write — and `:184` makes that clause govern over any dispatch word. ⛔ Nothing else in this body was touched, and ⛔ no verdict about the diff is written here: the clause-② review is an isolated at-tier reviewer's, and `needs:contract-review` stays on both carriers until it lands.</sub> --- _Generated by [Claude Code](https://claude.ai/code/session_019srGWGCBBCBHqcDoRZpQRh)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…d of sweeping them (objectstack-ai#19220) Fixes objectstack-ai#17797 The deliverable is a **classifier**, not a sweep. Three kinds of sentence live under one phrase and take different repairs, and only one of them is a defect. ## ⭐ The card's own headline number had decayed — which is the card's best self-evidence The card states that `scripts/pm/dispatch-gates.mjs` "alone carries **40** sentences containing `measured on this tree`". Re-measured at `0046a41b4`, 2026-09-20T00:30Z, with the card's own case-sensitive probe: **14**, of which **3** carry a revision by the card's own rev probe. A card about bare magnitudes decaying in silence had its own bare magnitude decay by 65% before anyone read it. ⛔ The `40` is not carried anywhere in this PR. ## 1. The declared population — which files, which phrase forms All readings below are mine, taken at `0046a41b4` (this branch's merge base), 2026-09-20T00:30Z.⚠️ Every one of them decays; the tool added here re-derives them. | probe | occurrences | files | |---|---:|---:| | the card's probe — `measured on this tree`, case-sensitive, over `scripts/` | 46 | 25 | | all three spellings that actually occur, case-insensitive — `measured on this tree`, `re-measured on this tree`, `measured against this tree` | 121 | 47 | | the same, excluding the two files scoped out below | 74 | 45 |⚠️ **That is an upper bound on a population, ⛔ not a count of defects.** The card says so in its own words and this PR does not contradict it. Three findings about the population itself, each of which says a phrase probe is not a population: - **The card's probe is case-folded shut.** Lower-case-only reaches 46 of the 121 sentences that carry the phrase in some spelling. The gap is not rounding. - **A line-based probe cannot see a sentence that wraps.** The phrase itself straddles a line break in five files, so no `grep` of it lists them at all: `scripts/check-examples-live-imports.mjs`, `scripts/check-parse-guard.mjs`, `scripts/check-self-test-wired.mjs`, `scripts/eslint-fatal-guard.mjs`, `scripts/i18n-bundle-surface.mjs`. The tool reads sentences, so it holds them. - **⭐ And the card's own named instance carries no phrase at all.** It is a bare corpus size in the present tense. A population defined by the phrase would have reported the tree clean at the one site everybody already agreed was broken — so the population is declared in two forms, FORM A (the phrase) and FORM B (declared sites the phrase cannot reach, currently the hand-written-corpus size in `scripts/docs-audit/README.md`). ## 2. The classifier `scripts/pm/measurement-claim-triage.mjs`: ``` node scripts/pm/measurement-claim-triage.mjs the population + every verdict node scripts/pm/measurement-claim-triage.mjs --review the kind-3 residue alone node scripts/pm/measurement-claim-triage.mjs --self-test the controls, both directions ``` It records **no** number about its own population — the scope line it prints is the number, re-derived on the tree it runs against. That is the durable half of the fix: the reason the card's `40` went stale is that it was written down instead of re-derivable. Four tests, applied in that order. Each can only move a hit **out** of the residue, and each prints the cue that moved it, so every verdict is auditable against the sentence. - **T1 FRAME** — is the magnitude scoped to a moment that has passed, or to a counterfactual condition? (`before this gate was written`, `at the commit that took it`, `an earlier revision`, `with the reservation removed`, `was auditing`) ⇒ **KIND 1**. - **T2 ANCHOR** — does the sentence carry a revision or a date? ⇒ **KIND 1**. ⛔ A bare card number is deliberately **not** an anchor: `#NNNN` occurs in every kind of sentence here, and admitting it would clear the residue by matching everything. - **T3 LOUDNESS** — would the drift announce itself? Three ways: a named failure direction (a zero whose falsifier is spelled out is the commonest), a pointer at the instrument that reprints the value, or a live assertion that reds on drift. ⇒ **KIND 2**. - **T0 MAGNITUDE** — last, and weakest: nothing decays if nothing is counted. A ZERO counts, because kind 2 is defined on one. - Everything else is **REVIEW**, a *candidate* bucket. ⛔ Reading its size as a finding count is the error the card exists to prevent. **T4 — level or relation — is a judgement and no regex decides it.** Does a reader act on the magnitude's **level**, or on a relation (a ratio, an ordering, an existence claim, a set equality) that the level's drift survives? A level is kind 3; a relation is an ILLUSTRATION, and rewriting it buys nothing. T4 is recorded per site in the tool's `TRIAGE` table with its reason, and a REVIEW hit with no row prints UNTRIAGED and reds the self-test. ⛔ **Every `TRIAGE` row is keyed by an excerpt, never by a line number** — the card cited its own known instance at `:493` and it was at `:555` by the time anyone read it. ### The window, and a bug worth recording The first build read a fixed ±6-line block and **cleared** the one site everybody had agreed was broken: six lines above it an unrelated sentence cites a revision, and a block window handed that anchor to a claim that has none. T2 and T3 now read the **sentence**; T1 also sees the sentence before (a narrative frame leads), T3 also the sentence after (a named failure direction trails — the card's own kind-2 exemplar spells the zero in one sentence and names its falsifier in the next); ⛔ T2 sees neither. That control is pinned in the self-test. ## 3. ⭐ Controls, both directions `node scripts/pm/measurement-claim-triage.mjs --self-test` → **green**, six controls: | control | site | expects | |---|---|---| | kind 1 — a citation carrying its revision, **left alone** | `scripts/check-tier-file-adoption.mjs` ("Measured on this tree at `d03c3c96d6` …") | `KIND-1` | | kind 2 — a zero whose failure direction is named, **left alone** | `scripts/docs-audit/affected-docs.mjs` ("zero commands declare `static topic` …" + "The failure direction if that ever changes …") | `KIND-2` | | kind 2 — a refusal a live assertion holds, **left alone** | `scripts/check-skill-compatibility-version.mjs` ("the refusal is pinned in the self-test …") | `KIND-2` | | **kind 3 — the card's named instance, CAUGHT** | `scripts/docs-audit/README.md` (pre-repair text) | `REVIEW` | | kind 3 repaired — the same site now points at the gate | `scripts/docs-audit/README.md` (post-repair text) | `KIND-2` | | ⛔ the one-sentence anchor rule — a neighbour's revision does **not** clear a bare magnitude | synthetic | `REVIEW` | Each control carries a liveness key that must still be present in the file it was quoted from (and the pre-repair one carries an `absent` key instead — if that sentence comes back, so has the defect). ⛔ A control quoting a sentence the tree no longer holds passes in silence, which is the failure this whole PR is about. The self-test also reds on a **dead cue**: every cue must still match something in the population or the controls — nine that matched nothing were deleted rather than left as decoration. ## 4. The kind-3 hits and their repairs Five, judged out of a pre-repair review residue of 27. After the repairs the tool reports `87 claim(s) over 48 file(s) — KIND-1 35 · KIND-2 11 · NO-FIGURE 19 · REVIEW 22`, and all 22 survivors are recorded ILLUSTRATION. ⛔ No fresh bare number is written anywhere in this diff. **Repaired by pointing at the live instrument** (objectstack-ai#16200's preferred shape): - `scripts/docs-audit/README.md` — "the anchor derivation reads the same **178**-page corpus the old one did". Present tense, and **wrong today**: `pnpm check:docs-audit-scope` printed `195 hand-written doc(s)` at 2026-09-20T00:11:30Z, my own reading. Repaired to name the corpus without sizing it and to send the reader to that gate, and it says out loud that the figure is **deliberately absent**. **Repaired by pinning the reading to the commit that recorded it** — the historical figure does work a live reading cannot (it justifies a decision taken at that moment) and no gate reprints it. Each carries a deliberately-absent note, so the next author does not helpfully restore a present-tense figure: - `scripts/check-pnpm-filter-targets.mjs` — the `scripts/**` declaration-honesty ratio, now `Measured at 52a41b7 (2026-08-23)`. The counts move with every file added under `scripts/`; the SHARE is what the paragraph argues. - `scripts/check-published-files.mjs` — the same shape over the whole publishable workspace, now `Measured at 52a41b7 (2026-08-23)`. - `scripts/check-skill-compatibility-version.mjs` — the four precision ratios deciding which roots are declared, now `measured at f29e897 (2026-08-22)`, with a pointer to `scripts/pm/bare-root-worklist.mjs`, which carries this gate's package-root rows with their own dates. - `scripts/check-slot-lookup-ratchet.mjs` — `46s`, the worst-ageing kind of figure: a wall-clock reading is a reading of one **box** as much as of one tree, so it decays without the tree moving at all. Now `Measured at 46s when this was recorded (99ca662, 2026-08-19)`, with the order (seconds against a CI round) left as the load-bearing claim. Each repair's excerpt is recorded `REPAIRED` in `TRIAGE`, and the self-test reds if it returns to the population. ### ⛔ What the classifier deliberately did NOT touch The 22 remaining REVIEW hits are recorded `ILLUSTRATION` with a reason each. Two worth naming, because they are the shape a sweep would have destroyed: - `scripts/docs-audit/README.md` lines carrying `178` in the historical narrative — the card predicted these were kind 1 and judging each one agrees. Two more, at the `--all` backstop and the cost note, already point at `check-audit-scope.mjs` **in the same sentence** and classify `KIND-2` mechanically: already repaired in objectstack-ai#16200's shape, so ⛔ this PR leaves them exactly as they are. - `scripts/check-dispatcher-error-vocabulary.mjs`'s per-glob member counts: kind 2, not kind 3 — the docblock names what would falsify them and `PUBLISHED_SOURCE_FACE_FLOOR` plus the per-glob presence pin red in that direction. ## ⛔ Two files are outside the file surface, for two independent reasons **`scripts/pm/dispatch-gates.mjs` and `scripts/pm/check-widening-tells.mjs` are not edited here**, and both reasons are carried as data in the tool's `EXCLUDED` table so every run prints them rather than leaving them in prose: 1. **Live conflict.** `dispatch-gates.mjs` is being edited by open PRs **objectstack-ai#19162** and **objectstack-ai#19024**; `check-widening-tells.mjs` by **objectstack-ai#19153** and **objectstack-ai#19024**. Editing underneath them is a conflict, not duplicated work. 2. **The card's own verification constraint**: `scripts/pm/dispatch-gates.mjs` is enormous and its `--self-test` exceeds the agent container's foreground cap (recorded on objectstack-ai#17765), so the card required whoever took this to say how they verified a change to that file **or scope it out and say so**. This is the saying-so. ⇒ The residue those two hold is **un-swept**, and the tool's scope line says so on every run instead of reading as complete. Also untouched, and reported rather than edited: `.claude/**`, `skills/**`, `docs/adr/**`, `AGENTS.md`, `CLAUDE.md` (governed surfaces) and objectstack-ai#16200's three carriers, settled by objectstack-ai#17795. ## Verification Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `60f9b648a` — 6 paths, 722 changed lines. **35 derived families, 35 run, all exit 0**; reconciled with `--ran`: `35 derived famil(ies) accounted for — 35 run, 0 UNRUN`. - `node scripts/pm/measurement-claim-triage.mjs --self-test` → `✓ controls hold in BOTH directions (6 controls, 27 recorded judgements)`. - `pnpm check:pm-dispatch-gates` → `✓ dispatch-gates self-test: 1866 cases pass`, **593.2s on this box**, `VERDICT command-exit 0`, run detached under `scripts/pm/os-verify-lock.sh` (held 594s, waited 0s), 2026-09-20T00:23:16Z–00:33:10Z.⚠️ The reference line at `content/docs/qa/platform-readings.md:432` records 430–450s; this container reads well above that band, and this run is one more point in it. - ESLint, narrowed and the narrowing measured: `npx eslint --no-inline-config --format json` over the five changed `.mjs` files at `60f9b648a` → **5 files linted, 0 errors, 0 warnings** (counts read from the JSON reporter). The narrowing excludes nothing: `eslint.config.mjs` enables type-aware linting for **no** configuration in this repo (no `parserOptions.project`, no typed rules), so this diff cannot move the verdict on a file it does not touch. The repo-wide `eslint .` run is CI's. - ⊘ **NOT MEASURED** — `pnpm check:published-readme-exports` and `pnpm check:dts-closure` both exited **3, PREREQUISITE NOT MET** (46 packages' `dist/` not built). Neither is in the derived family; both are artifact-roster families whose roster sits under `scripts/`, run here only because silence there is not evidence. This diff changes no package source, so neither could be moved by it. ⛔ Recorded as not measured, not as a pass. - No changeset: measured, not assumed. The root manifest is `private: true` and no package's `files[]` names the repo-root `scripts/` tree, so nothing published moves. Labelled `skip-changeset`. ## Acceptance notes (out of scope, noted and not filed) - `scripts/pm/dispatch-gates.mjs` holds 42 of the 121 phrase occurrences (all spellings) and is un-swept here for the two reasons above. **Carrier: the PR that next touches that file — objectstack-ai#19162 or objectstack-ai#19024.** Worth a card of its own once they land; the tool classifies it the moment its `EXCLUDED` row is removed. - `scripts/pm/check-widening-tells.mjs` holds 5, same disposition. **Carrier: objectstack-ai#19153 / objectstack-ai#19024.** - The tool is **not CI-wired**: a `package.json` script and a workflow step both sit outside this card's file surface, so `--self-test` is run by hand and its controls watch nothing on their own. The docblock says so out loud. ⛔ Do not read a green run here as CI coverage. **Carrier: none today** — it needs a seat that owns the root manifest and the lint workflow. Raised as an open question in the report rather than filed, because wiring it is a decision about CI cost, not a defect. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ Co-authored-by: Claude <noreply@anthropic.com>
…ED, not a census takedown (objectstack-ai#19290) Fixes objectstack-ai#19077 Clause-②: no Item **2** of objectstack-ai#19077 — the census-side application. Item 1 (`parseDerivedText`, the returnable door) landed as PR objectstack-ai#19112, squash `956e0107a`; nothing here re-does it. Base: `origin/main` = `215840f4353`, fetched 2026-09-20T09:07Z. Every reading below was taken on that base in a dedicated worktree, never on the shared checkout. ## The defect, and what it did `scripts/tenant-audit-census.mjs` stores a declared type's text whitespace-collapsed and re-parses it as a synthetic alias (`type CensusReceiver = ...;`) to read the engine-door rule off it. A type literal may separate its members by a **newline alone** — legal TypeScript — and the collapse turns that separator into nothing, so the synthesis does not parse. Through `parseSourceFile` that did not fail the SITE: it ended the process, and every other site in the corpus lost its verdict with it, under a refusal naming `census-receiver-type.ts`, a file that does not exist in the tree. ## The repair — the four points of the minimal hunk, and where each landed | point | landed | |---|---| | ① the door rule takes the origin `sf` the type text came from | `readTypeTextDoor(typeText, origin)`; `resolveReceiver` hands its own `sf` at all four `inlineEngineDoorOrOther` call sites | | ② the synthetic alias goes through `parseDerivedText` | `readTypeTextDoor`, one call site, the only synthesis in the file | | ③ on failure the report is printed against the SITE and the door reads `false` | the located verdict rides on the site as `derivedFailure`; `main()` prints it under a per-site `::error::` line | | ④ a **declared** `NON_ENGINE_REASONS` arm, placed in `UNDEFENDED_REASONS` | `type-text-not-round-trippable`, in both artefacts under ENFORCEMENT | ## ⛔ Point ④ and the floor — why this PR also edits the gate The non-negotiable is that no importer ends up exiting 0 where it exits non-zero today. Measured, rather than assumed: `lint.yml:2007-2008` invokes `scripts/check-tenant-audit-census.mjs` and **never the generator**, and that gate's own docblock says why (`censusRefusals` exists because `runCensus`'s findings were read by nothing on the way to a CI verdict). So on a corpus holding such a receiver: - **today** — `runCensus` calls `parseSourceFile`, which exits 3; the gate dies with it, CI red. - **with the repair and no gate arm** — the site would be declared in the artefacts, the author would regenerate, and the gate would go **green**. That is the loud process exit traded for a quiet subtraction: the same floor drop in a different costume. - **as landed** — the site is classified, declared in both artefacts, printed against its own file and line with the parse verdict under it, **and refused** by `censusRefusals` through `notRoundTrippableSites`, the one spelling the generator and the gate share. `main()` counts it in its exit code too, for the local run. ⇒ the exchange is a process-wide takedown for a located, per-site refusal. Nothing that exits non-zero today exits 0 after this, and no site leaves the population in silence. ## Acceptance — triage's two inputs, as a pair Both run in `tenant-audit-census.mjs`'s own self-test, which CI drives through `node scripts/check-tenant-audit-census.mjs --self-test` (`lint.yml:2007`). Ten new cases; the file's self-test goes 49 to 59, the gate's 24 to 27, and the gate's `census refusals` battery floor is raised 5 to 7 so the new cases cannot stop running unnoticed. 1. **A newline-separated inline type literal is CLASSIFIED.** The case runs the REAL round trip — a source is parsed, `declaredTypesIn` collapses the declared type exactly as the census does, and the resolver reads the door off the stored text — and reads `other/type-text-not-round-trippable`. **Lit control:** the semicolon spelling of the SAME literal is still PLACED as `engine/inline type literal stating an engine door`, so the first case is not a door that rejects everything. Two more: the arm is in `UNDEFENDED_REASONS` (control: a defensible arm is not), and the failure is carried as located data naming the SOURCE it was derived from. 2. **A genuinely unparseable input is still REFUSED.** A garbage type text lands on the same declared arm and no door is read off it, so it is never placed. The corpus door is untouched: sources are still read through `parseSourceFile`, whose process-exit refusal is pinned by `ts-parse.mjs --self-test` (landed with item 1) and enforced for every `scripts/**` caller by `check:parse-guard` (exit 0 here). **Boundary, pinned:** the verb gate runs first, so a newline-separated literal naming no write verb is never synthesised and cannot reach the new arm — the repair's reach is exactly the defect's reach. ## The census over the live corpus, before and after `node scripts/tenant-audit-census.mjs`, base `215840f4353`: - **before** (2026-09-20T09:08Z): `exit 0`, **576 sources**, 227 write call sites. - **after** (2026-09-20T09:20Z): `exit 0`, **576 sources** — output **byte-identical** (`diff` exits 0), one undefended subtraction, reason `type-not-in-corpus`, door-shaped 0.⚠️ The previous round measured 573 sources; `main` moved between the rounds. The escalation trigger did **not** fire: exit 0 means no receiver of this shape exists in the tree, so the card stays latent and the grading stays triage's. ## Reverse verification — three ablations, each restored byte-identical Driven through `scripts/ablation-replace.mjs`, which proves the mutation landed on disk (anchor count fell, replacement count rose, blob hash changed) and proves each restore (`blob == HEAD blob`, `git diff HEAD` empty). 1. **The door reverts to `parseSourceFile`** — the self-test does not fail, it **dies**: `exit 3`, printing the card's own refusal for `census-receiver-type.ts` with `1:78 ';' expected`. That is the defect, reproduced from the acceptance case. 2. **The arm is removed from `UNDEFENDED_REASONS`** (point ④'s own ablation) — RED, the declaration pin fails, 1 of 59. 3. **The gate's refusal loop is emptied** — RED, 2 of 27 gate cases fail, which is the "quiet subtraction" costume failing to pass.⚠️ Reported rather than quietly retried: ablation 2's first attempt was **refused** by the helper because the replacement text was a substring of the anchor, so the on-disk count could not rise. It restored and never ran the command; the rerun used a dropping anchor. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, derived from this tree at `ce1b5304101` (4 paths vs merge base `215840f43`, three-dot): **64 families, all exit 0**, each captured BEFORE any pipe, reconciled with `--ran`: `64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN` — a DERIVED zero, every family carrying a code. Five of the 64 first answered `PREREQUISITE NOT MET` (exit 3 / exit 1: not findings, nothing measured) and were re-run after building what they read — `pnpm --filter '@objectstack/lint...' build`, then `pnpm --filter '@objectstack/client-react...' --filter '@objectstack/client...' build`, both through `scripts/pm/os-verify-lock.sh` (`VERDICT command-exit 0`). All five then exit 0. Outside that total by the tool's own accounting: 52 artifact-roster families, 11 declared wide-population families, 14 pending-changeset families, 2 path-scheduled CI jobs. `pnpm lint` repo-wide is CI's run. The narrowing here is DECLARED and carries all three readings: (1) the reachable population comes from eslint's own config text — `eslint.config.mjs:328` records that this repo runs one config that never enables type-aware linting (no `parserOptions.project`, no typed rules) for ANY file, measured there with a positive control; (2) the targeted count is read from `--format json`: 4 files, 0 errors, exit 0 — the two markdown artefacts report "File ignored because no matching configuration was supplied", i.e. eslint does not lint them at all; (3) invariance — with no type-aware linting anywhere, a 4-file diff cannot move the verdict on a file it does not contain. Taken at `ce1b5304101`. ## Changeset `skip-changeset`, on three readings: (1) the root package is `private: true`; (2) all **70** non-private workspace packages declare a `files[]` (lit control) and **0** of them have an entry that climbs out of its own package directory; (3) none of the four changed paths is under any package directory. ⇒ root `scripts/**`, `content/docs/**` and `docs/audits/**` cannot be in any published payload. `Clause-②: no` follows: this moves a build-time gate's failure convention, not `packages/spec`'s accept set or any public surface. ##⚠️ Declared deviation — three files outside the dispatched file surface The dispatch declared `scripts/tenant-audit-census.mjs` and new tests under `scripts/`. This PR also touches: - `scripts/check-tenant-audit-census.mjs` — the gate-side half of point ④, argued above. Without it the repair lowers the floor, which the dispatch forbids outright. - `content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` — GENERATED regions, rewritten by `node scripts/tenant-audit-census.mjs --write`, which is the only legal way to edit them and is what `check-tenant-audit-census.mjs` demands. Point ④ puts the arm in the enforced undefended table, so the table's prose had to name the third cause or the artefact would explain a row it does not cover. Measured before touching them, 2026-09-20T09:1xZ: **22 open PRs, 623 file rows** (PR objectstack-ai#17076 paged past the 100-row cap so the negative is not a truncation artefact) — **no open PR holds any of the four paths**, nor `scripts/ts-parse.mjs`. Firing control: the map does carry `scripts/` rows (objectstack-ai#19024, objectstack-ai#18414, objectstack-ai#19153, objectstack-ai#18723). Dark control: `scripts/zzz-no-such-file.mjs` has no row.⚠️ Also in the regenerated artefacts and NOT caused by this change: the unenforced corpus-scale block re-stamps its date and sha and moves 573 to 576 tracked sources, because `main` moved since it was last written. ## Acceptance notes — noted, not filed - Triage's **route ①** (stop collapsing the declared type text at storage time) stays available and would let such a receiver be PLACED rather than declared-undefended. It is a much wider change — the collapsed text is also the display text, the index-lookup input and the ledger key — and it is not what the sequenced hunk asked for. Successor: any later card that revisits the census's stored type text. Not a defect in the tree. - The same defect class at the transpile door (`transpileChecked` on a lifted snippet) was already filed as its own card by the `domain:spec#3` seat, with the boundary carried over. ⛔ No second card from here. - No path literal was added to `scripts/tenant-audit-census.mjs` or `ts-parse.mjs`; `check:watch-hint-literal` exits 0. Successor: none needed. --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ Co-authored-by: Claude <noreply@anthropic.com>
… so a deferred table stops deriving as a scan surface (objectstack-ai#19308) Fixes objectstack-ai#19260 Clause-②: no `EXCLUSION_DECL_NAME` in `scripts/pm/dispatch-gates.mjs` listed no `DEFERRED`, while `scripts/check-issue-citations.mjs` declares its exclusion table under exactly that word — `DEFERRED_SURFACES` / `DEFERRED_GLOBS` — and applies it as an exclusion: `surfaceFor` opens by returning `null` for every deferred glob. So the derivation read an **exclusion** table as a **scan** surface, and a `.changeset/**` path was told it triggers a gate that looks at nothing there. That derivation is the one PR objectstack-ai#19259 (card objectstack-ai#18224) trips on, and the assertion it reds — 「a changeset path alone reaches NO value-bearing family any more」 — is correct and is untouched here. One `DEFERRED` branch in the predicate; the measured census in the docblock above it re-measured and rewritten in the same stroke; one self-test case beside the existing named-spelling cases. ⛔ Nothing is renamed in `check-issue-citations.mjs` — `DEFERRED_SURFACES` and `DEFERRED_GLOBS` are exported and pinned by that gate's own self-test, and this docblock says the fix belongs on the derivation side: 「an author's next spelling should be met by this predicate rather than by a rediscovery of this card」. ## Acceptance notes ### Firing pair, both directions, on PR objectstack-ai#19259's head The script derives from the tree it LIVES in — `ROOT` comes from `import.meta.url`, and `trackedFiles()` reads that root — so a `cwd` does **not** redirect it at another tree. The fixed file was therefore copied into a detached worktree of PR objectstack-ai#19259's head (`6d1272b3850e5c0e756745dc6486d20aa330653d`, blob `5d78406d6` before, `8216826a5` while mutated) and run from there; the probe was restored with `git checkout HEAD -- scripts/pm/dispatch-gates.mjs` back to blob `5d78406d6`, `git diff HEAD` empty. `.changeset/test-abc.md` did not need to exist. | probe tree `6d1272b385` | `--commands` lines | `pnpm check:issue-citations` rows | |---|---|---| | subject `.changeset/test-abc.md`, before | 19 | **1** | | subject `.changeset/test-abc.md`, after | 18 | **0** | | control `packages/types/src/index.ts`, before | 44 | **1** | | control `packages/types/src/index.ts`, after | 44 | **1** | The subject's two command lists differ on exactly one line — `diff` reports hunk `13d12`, deleting the `pnpm check:issue-citations` line and nothing else — the control's two lists are byte-identical. Command: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack PATH`, exit 0 on all four runs. ### The census: the method it never stated, and what it now reads The block named its method only in prose and named no command, so the method is now spelled out in the block itself: enumerate `topLevelDecls` over every tracked JS/TS file under `scripts/`, keep the non-callable declarations, test each name against the predicate, and price the arm by diffing `extractWatchHints` against a build of this module whose predicate matches nothing. Attribution is the regex engine's own — leftmost position first, then alternation order (the old text said "first-match attribution" without saying which of the two, and they disagree on a multi-word name such as `EXCLUDED_SKIP`). Measured over the objectstack-ai/objectstack tree at `e6a03e6491`, with the export-visibility of six internal bindings as the only difference from the shipped file (a throwaway copy, never committed): | reading | docblock before | re-measured, no `DEFERRED` | re-measured, with `DEFERRED` | |---|---|---|---| | tracked JS/TS files under `scripts/` | 230 | 287 | 287 | | top-level VALUE declarations | 3,513 | 4,697 | 4,697 | | identifiers matching the predicate | 64 | 73 | **75** | | of those, carrying a string literal | 58 | 65 | **66** | | hints the set moves | 14 | 16 | **24** | | files it moves them on | 6 | 7 | **8** | | change no derivation / really leave one | 8 / 6 | — | **13 / 11** | Per-word tally, same ordering as the block: `SKIP 54, EXCLUDED 10, EXCLUSION 4, NOISE 2, SKIPPED 2, DEFERRED 2, EXCLUSIONS 1, and EXCLUDE / EXCLUDES / IGNORE / IGNORED 0` — four zero-scoring arms now, not three, because `EXCLUDES` went 1 → 0 on its own.⚠️ **Every one of those numbers was already stale before this card**: the middle column is the same tree with the OLD predicate, so the drift from `230 / 3,513 / 64 / 58 / 14` is the tree moving, not the arm. Nothing reds when it does, which is why the block now carries the tree ref it was read on. `DEFERRED` matches exactly two declarations on this tree, both in `check-issue-citations.mjs`, and both are that gate's own exclusion table. It moves 8 hints off that gate, 5 of which cost nothing — the test globs sit under the gate's own `packages/**` inclusion, so `hintCovers` still reaches a test path — and **3 of which were fabricated leads**: `.changeset/**` (the one the card measured), plus `scripts/**` and `docs/adr/**`, which nobody had named. The gate stays reachable through `packages/**`, `packages/**/src/**/*.ts` and its siblings, which is what keeps the control lit. Re-verified after the merge of `origin/main` `e6a03e6491`: every figure above reproduces, and the edit adds no top-level VALUE declaration to this file (112 before, 112 after), so the 4,697 holds for the delivered tree. ### Gates — every exit code captured before any pipe | command | exit | verdict line | |---|---|---| | `node scripts/pm/dispatch-gates.mjs --self-test` (this branch) | 0 | `✓ dispatch-gates self-test: 1867 cases pass.` | | `node scripts/pm/dispatch-gates.mjs --self-test` (pristine `e6a03e6491`) | 0 | `✓ dispatch-gates self-test: 1866 cases pass.` | | `pnpm check:pm-dispatch-gates` (detached, under the shared lock) | 0 | `check:pm-dispatch-gates: the battery took 1012.6s on this box.` | | `pnpm lint` (repo-wide, `eslint . --no-inline-config`) | 0 | no output | | the other 27 derived families | 0 each | listed below | Case count 1866 → 1867: the one new case is the `DEFERRED` entry in the named-spelling loop beside the existing exclusion-vocabulary cases, ⛔ not at the tail of `selfTest()` — the tail regions that PR objectstack-ai#19162 (`:22173`–`:22203`, now landed as `e6a03e6`) and PR objectstack-ai#19024 (`:23763`) touch are untouched here, and the merge of `origin/main` carrying objectstack-ai#19162 was clean. Inside the battery the case 「⭐ a changeset path alone reaches NO value-bearing family any more」 — the one red on PR objectstack-ai#19259's head — now reads green. Families derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (28, recomputed post-merge and identical to the pre-merge list) and reconciled with `--ran`: `28 derived famil(ies) accounted for — 28 run, 0 NOT-MEASURED (a DERIVED zero — all 28 recorded an exit code and none of them is 3)`. The 27 besides the battery: `check-ci-filter-parity`, `check-closing-keyword-parity` (+ self-test), `check-comment-mask-corpus`, `check-declaration-mirrors` (+ self-test), `check-scripts-symbol-anchors` (+ self-test), `check-self-test-wired` (+ self-test), `check-self-test-workflow-commands` (+ self-test), `check-whole-set-label-write` (+ self-test), `check:agent-test-spelling`, `check:bash32-floor`, `check:cli-command-ids`, `check:cross-package-test-inputs`, `check:declared-population-live`, `check:driver-memory-census`, `check:entry-guard`, `check:nul-bytes`, `check:parse-guard`, `check:pnpm-filter-targets`, `check:ratchet-remedy-authority`, `check:refd-timer-probe`, `check:watch-hint-literal` — all exit 0. Control-byte self-scan beyond the gate, exit captured before any pipe: `grep -naP` over the changed file for the C0 range plus DEL exits 1 — none present. ### Line budget and shape `scripts/pm/dispatch-gates.mjs` 28,345 → 28,371 lines against `origin/main` `e6a03e6491`: **net +26** (+64 / −38), inside the +40 the dispatch set. Measured against the 28,337 the dispatch quoted at `7d0f911`/`7ec8534`, the file reads +34, of which +8 are PR objectstack-ai#19162's own. `git diff --stat` shows one file. No changeset: `scripts/` ships in no published package's `files[]` (checked across every non-private manifest in the tree), and `scripts/pm/**` is on the non-publishing fast track.⚠️ So `Check Changeset` needs the `skip-changeset` label, and this dispatch forbids label writes to the dev — **that one write is the seat's**, not left undone by accident. ### Noted, not filed **The census in this docblock was already stale before this card, and nothing reds when it goes stale.** The middle column of the census table above is the same tree read with the OLD predicate: `230 → 287` files, `3,513 → 4,697` declarations, `64 → 73` matches, `14 → 16` moved hints, and the tally's `EXCLUDES 1 → 0`. The sibling gate keeps its own census as code with a `measuredOn` ref (`CENSUS_17512` in `check-issue-citations.mjs`, marked 「⛔ Readings, not a budget」); this one is prose in a comment, so it rots silently. Not filed — it is an observation about a missing guard, not a reproducible defect, a broken declared contract or an authoring trap. 承接者: the standing queue on this same file (objectstack-ai#19070 → objectstack-ai#19104 → objectstack-ai#19105 → objectstack-ai#19106 → objectstack-ai#19172), any of which reads this block. The mitigation this PR does ship is the tree ref and the spelled-out method, so the next reader can tell a stale number from a current one. ## 维护者速读(草稿) **改了什么** — 派单工具 `dispatch-gates` 里那张「哪些常量名代表『这个门禁不看这里』」的词表,补上了 `DEFERRED` 这个拼写。顺带把该处注释里那份实测普查重新测了一遍并改写,因为它早已过期。 **为什么改** — `check-issue-citations` 这个门禁用 `DEFERRED_SURFACES` 声明它**故意不看**的路径。 词表不认这个词,于是工具把「不看的清单」读成了「要看的清单」,反过来告诉开发者:改一个 changeset 文件会触发这个门禁 —— 而该门禁对 changeset 路径其实什么都不做。后果不止是一条假线索:它还让 PR objectstack-ai#19259 在 `lint.yml` 第 32 步整条中止,objectstack-ai#18224 自己新加的两步从未执行,卡住了一张 p2。实测这条修法还顺手消掉 另外两条没人发现的假线索(`scripts/**` 与 `docs/adr/**`)。 **风险与代价(含回滚)** — 风险很低:改动是一个正则分支加注释,只影响派单提示里「你该跑哪些门禁」这份 清单,不影响任何门禁自身的判定,也不改任何对外发布的包。方向上只会**少给**一条线索、不会多给,而这一侧 的失效代价是「一张卡多跑一轮 CI」,比反方向「每张卡都被塞一条假线索」便宜得多 —— 这个不对称是该文件自己 写下的判据。回滚 = revert 这一个提交,单文件、无生成物、无迁移。 **席位意见** **你要做的** — ① 这是 draft PR,按席位流程补 `## Contract review` 记录后再走 ready + auto-merge; ② `skip-changeset` 标签需要席位来打(本单禁止开发侧写标签),否则 `Check Changeset` 会红; ③ 落地后 PR objectstack-ai#19259 / 卡 objectstack-ai#18224 即可重跑,它那条断言本身是对的、本 PR 未动。 --- _Generated by [Claude Code](https://claude.ai/code/session_01W5y9kRg1YtYaMQYExVLRc2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…cute, and retire the two they never did (objectstack-ai#19364) Part of objectstack-ai#17667 Clause-②: yes Ruling of record: comment `5651023067` — director seat, decision batch objectstack-ai#126 item 1, maintainer 「同意」 (live PM chat 2026-09-13) to `1(2)·2A·3A·4B`. **Route 2**: the door's declaration and its reads are aligned. ⛔ Not re-adjudicated here. `Part of`, not `Fixes`, and the reason is measured rather than cautious — see **Why this does not close the card** below. The dispatch asked for `Fixes objectstack-ai#17667`; that instruction is overridden by the standing rule that a merge which should not close a card uses `Part of` and names the half it leaves. Flagged rather than silently chosen. ## STEP ZERO first — the ruling's own precondition did NOT stop the work Ruling item 4 makes the dispatch's first act a stop condition: if a platform-wide cursor convention already exists and `/packages` is the only holdout, route 1 **by reuse** is re-priced and the taker stops. Measured in this worktree at `81e12e1`, 2026-09-20T12:05Z: - **No shared pagination helper reaches any REST list door.** The only cursor codec in the tree is `encodeStorageListCursor` / `decodeStorageListCursor` (`packages/spec/src/contracts/storage-service.ts`) — the storage-**adapter** `list()` contract. Imports of it outside `service-storage` and its own contract file: **zero**. Imports of any `Cursor`-named symbol by `packages/runtime/src/**` or `packages/rest/src/**`: **zero**. - **Lit controls on the same greps, so those zeros are readable.** `parseIntegerParam` (`packages/runtime/src/query-param.ts`) IS found shared across two dispatcher domains, and `refuseUnknownQueryParams` IS found shared across two `packages/rest` files. The search finds shared helpers when they exist. - **`/packages` is not the only holdout — it is one of four.** `ListExportJobsRequestSchema`, `ListAiConversationsRequestSchema` and `ListRunsRequestSchema` all declare `limit` and/or `cursor`; none paginates. `GET /automation/:name/runs` even validates `cursor` at the boundary and then returns `{ runs, hasMore: false }`, with its own comment recording that "today's engine ignores the option entirely" — the same shape as this door, one domain over. - **The platform's travel is the other way.** `api/ListNotificationsRequest:cursor` (objectstack-ai#6361) and `data.query.cursor` (objectstack-ai#4286) were both retired before this one. ⇒ the stop condition is false in both of its conjuncts. Proceeding to items 1 and 3 was measured, not assumed. ## Ruling item 1 — declare what the doors already execute | door | parameter | read site | now declared on | |---|---|---|---| | `GET /api/v1/packages` | `type` | list branch, `manifest.type` equality | `ListInstalledPackagesRequestSchema` | | `GET /api/v1/packages/:id` | `version` | `readRequestedVersion(query?.version)` | `GetInstalledPackageRequestSchema` | | `DELETE /api/v1/packages/:id` | `keepData` | uninstall branch | `UninstallPackageApiRequestSchema` | | `POST /api/v1/packages` | `overwrite` | install branch | **already declared — see below** | No accept set moves: the doors served all four before and serve them identically now. Each declaration is measured from the handler's actual read, not from the card: - **`type`** is an open `z.string()`, deliberately not an enum. The door compares `manifest.type === query.type` on any non-empty value, and `ManifestSchema.type` is no shared vocabulary — a narrower declaration would state a rejection this wire does not perform. An unmatched value is not an error; it selects nothing. - **`version`** is a plain string. `latest` means "the installed row" and is equivalent to omitting the key; comparison is exact string equality against `manifest.version`, with no semver-range semantics, and the id is resolved first so an unknown id keeps its existing 404 wording. All of that is in the key's docblock so the next reader does not have to open the runtime. - **`keepData`** is declared boolean, and the docblock records the two spellings the wire actually honours — `keepData=true` and `keepData=1` — and warns that anything else, `keepData=yes` included, reads as absent and DROPS the tables. Widening the door's own comparison would be a runtime change this declaration is not. ###⚠️ Premise drift: `overwrite` was already discharged, by the PR that unblocked this card The card's body (2026-09-11) lists `?overwrite=` as read-and-undeclared. That is no longer true. PR objectstack-ai#19130 merged 2026-09-20T11:11:16Z — the same landing this card had been serialised behind — and it declares `overwrite: z.boolean().optional()` on `PackageInstallRequestSchema`, with a docblock that already names the `?overwrite=true` query spelling. One quarter of ruling item 1 needed nothing. **No edit was made for it**, deliberately: re-declaring it would have been churn, and the existing declaration is better than one written from the card. ## Ruling item 3 — retire `limit` and `cursor`, `.default(50)` included Both keys are `retiredKey()` tombstones, not deletions. The schema is not `.strict()`, so a bare deletion makes Zod silently strip whatever a generated client keeps sending — a clean parse and a parameter that never takes effect, which is this card's own defect moved one layer down (ADR-0104). Writing either key is now a `tsc` error and a parse error carrying the prescription. The prescription names the removed default specifically, because that is the load-bearing half: a reader of the published schema was entitled to believe an unparameterised list is capped at 50 rows, and it has never been capped at all. **The retirement kit, and the two entries it deliberately does NOT have.** Precedent hunted and followed: `api/ListNotificationsRequest:cursor` (objectstack-ai#6361) is the same shape one route over — an HTTP-only request key retired with a tombstone and a D3 semantic entry. Zone 2 flagged this precedent as unverified by the seat; it exists, and this change copies it. - `RETIRED_KEYS_BY_MAJOR[18]` — two entries, one file each, generated into `migrations/registry.ts` by `gen:migration-registry`. - D3 semantic entry `packages-list-pagination-retired`, carrying `surface` / `replacement` / `reason` / `acceptanceCriteria` to `spec-changes.json`, the generated upgrade guide and `os migrate meta`. - Registered at **18, not 17**, per the `ui/ListView:pageName` and `security/ObjectPermission:allowPurge` convention: the removal ships on the 17.x line as a minor, and the prescription lives at the major boundary where `migrate meta` users look. The guidance string says `17.5.0`, the shipping version, matching `view.pageName`. - **No D2 conversion**, and the asymmetry is the point: a conversion rewrites an authored source or a stored `sys_metadata` row, and this shape is HTTP-only — nobody authors a `ListInstalledPackagesRequest` and nothing persists one. The `os migrate meta` house sentence is therefore correctly absent from the prescription; the pin only judges prescriptions that name the command. - **No `acceptRetiredDefaultResidue` stage**, for the same reason one layer along. That helper exists for a retired default the published toolchain materialized into built artifacts. Nothing has ever parsed this schema, so the `.default(50)` reached no artifact and there is no residue population. The `authorable-defaults/api.json` line simply leaves with the key — `DEFAULT_CHANGES_BY_MAJOR` excludes retirements by name, and `check:authorable-surface` accepted it without one. - **No liveness-ledger row** to touch: `liveness/api.json` is the `api` METADATA type's ledger, not the spec `api/` category. Zero occurrences of `ListInstalledPackages` in it. **Ratchet readings, stated because their direction is route-dependent:** `authorable-surface/api.json` gains two `[RETIRED]` rows and three new keys; `authorable-defaults/api.json` loses exactly the `= 50` line; `api-surface/` is unchanged, which is correct for a key-level narrowing on a surviving def. ### `hasMore` is now true by construction, and the comment says so `hasMore` stays the constant `false` it already was. With no `limit` and no `cursor` to ask with, nothing can request a page, so there is never a next one to announce. That is recorded at the response declaration — the return site itself is in `packages/runtime/src/domains/packages.ts`, which this dispatch is fenced off — with an explicit warning against "fixing" the constant back into a computed value before a request-side way to ask exists. Pinned by a test. ## Why this does not close the card Ruling item 2 — `enabled` implemented in `packages/runtime/src/domains/packages.ts`, one filter line in the shape `status` already has — is assigned by the ruling to the **cli seat's sibling PR** and is fenced off this dispatch. Measured on the merged `origin/main` at `2277d1f`, 2026-09-20T13:30Z: `query?.enabled` occurs **0** times in that file; control on the same file, `query?.status` occurs **1** time. So `enabled` is still declared-and-unread after this PR, which is one live instance of the very class this card names. That is recorded in the schema docblock rather than glossed, and it is why the closing line is `Part of`. PR objectstack-ai#19326, which held that file during dispatch, turns out to be the manifest-`version` change for objectstack-ai#19120 and has merged; it is not the `enabled` sibling. ## Verification Readings taken in this worktree; the gate union below was run after the final commit, at `80937f5`. - **Gate family, derived from the real changed paths** (`scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived after the merge): **107 derived · 104 run green · 3 NOT MEASURED · 0 UNRUN · 0 red**, reconciled with `--ran` carrying each exit code captured before any pipe. The three NOT MEASURED are the gates' own `exit 3` PREREQUISITE NOT MET: `check:dual-build-cjs-loads` (wants a whole-repo build), `check:type-check-debt` (a re-measure, which exits 3 by design and is a maintainer's act to act on), and `check-plugin-teardown-shape --self-test` (wants an unshallow checkout). None is a finding. - `pnpm --filter @objectstack/spec check:generated` — **all 15 artifacts up to date**, at the final head. - `pnpm --filter @objectstack/spec test` — **501 files / 14662 tests passed**. `pnpm --filter @objectstack/spec typecheck` — clean. - `check:test-typecheck` GRADUATED `src/api/package-api.test.ts`: its two recorded `TS6133` unused-import errors are gone because the new tests use both symbols, so the shrink-only ledger entry is deleted in this PR, as that ratchet requires. - **Reverse verification (one-off, not left in the tree).** The `limit` tombstone was replaced on disk with its old `z.number().int().min(1).max(100).default(50)` via `scripts/ablation-replace.mjs`, which proved the write landed (anchor 1 → 0, blob `de8722127dc3` → `10542945489d`) before running anything. Direction observed: **red**, 2 failed / 68 passed — both the prescription pin and the absence pin fire. Restore verified by blob identity against `HEAD` and an empty `git diff HEAD`, by the tool, not by an exit code. - **Absence sweep, tree-scoped, with lit controls.** Authoring sites for `limit` / `cursor` on this request shape outside the new registry entries: **zero**; `packages.list(` calls passing either: **zero**. Controls: `overwrite` IS found in the same spec file (10 hits) and `packages.list(` IS found across five files by the same pattern shape. The first-party SDK already declares `list(filters?: { status, type, enabled })` — no `limit`, no `cursor` — so unlike objectstack-ai#6361 there is no shipped producer to delete alongside the key. ## Acceptance notes Out of scope, noted and deliberately not filed: - **`gen:api-surface-declarations` output was not stable across builds of identical sources**, and it cost this run a wrong turn worth recording. Build objectstack-ai#1 of the unchanged `ui` sources emitted one enum-member ordering, build objectstack-ai#2 emitted another; 184 lines of `api-surface-declarations/ui.txt` flipped between them, and a single control build at BASE reproduced BASE — which made one sample look like proof that my diff caused the churn. It did not. The correct reading needed three builds. **This finding has no surviving consumer**: `origin/main` at `2277d1f` reverted the whole declaration-text snapshot (objectstack-ai#19024) and deleted `api-surface-declarations/` along with its gate, which is also the merge conflict this branch resolved by accepting the deletion. Successor: none. Recorded here rather than filed because the artefact and the gate that read it no longer exist. - **Three sibling list doors carry the same declared-not-honoured pagination shape** — `ListExportJobsRequestSchema` (`limit` with `.default(20)`, `cursor`), `ListAiConversationsRequestSchema` (`limit`, `cursor`) and `ListRunsRequestSchema` (`limit`, `cursor`, the last validated at the boundary and then ignored by the engine, with `hasMore: false` hard-coded). This is a reproducible contract divergence of exactly this card's class, on doors this card does not name, and the handback report carries it with dedupe words for the seat to file. ⛔ Not filed from here and ⛔ not widened onto this PR. - The `/packages` dispatcher domain declares no closed query-parameter set, so an unrecognised name is still dropped rather than refused. That is route 3, which the ruling considered and refused; noted so a later reader does not read this PR as having taken it. Successor: whoever converts the dispatcher domains per the incremental ingress lane. Landing waits for the seat: this PR is a contract-review carrier and the seat handles both the carrier and the at-tier review. Nothing here flips ready, enqueues, arms auto-merge, requests review, or writes a label or assignee. --- _Generated by [Claude Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…-only), plus the merged-result probe (objectstack-ai#19259) Fixes objectstack-ai#18224 Two gates were registered in the root manifest and invoked by **zero** workflows: `scripts/check-issue-citations.mjs` (delivered by objectstack-ai#18223 / card objectstack-ai#17512) and `scripts/check-merged-result.mjs` (delivered by objectstack-ai#18338 / card objectstack-ai#16287). Both cards' declared file surfaces excluded `.github/workflows/**`, so both devs correctly stopped and filed the wiring rather than widening. This PR is that wiring. ## The three entry points, and their postures | entry | lane | posture | | :--- | :--- | :--- | | the DIFF-scoped citation verdict | `lint.yml` · `Lint & Repo Gates` | **blocking** | | `check:merged-result` self-test | `lint.yml` · `Lint & Repo Gates` | **blocking** | | `check-issue-citations --census` | `half-state-patrol.yml` (scheduled) | **report-only, never blocking** | The census posture is a ruling, not a preference, and the card carries the measurement that forces it: **objectstack-ai#16783, objectstack-ai#16786 and objectstack-ai#16787 were measured RESOLVING on 2026-09-10 and 404 on 2026-09-14, with no change to this tree.** A tree-wide blocking verdict would therefore red a motionless repository because a third party deleted an issue. The diff-scoped half is the part an author owns, it is small, and it is the only thing that stops 2,785 unresolvable sites becoming 3,000. ## The manifest alias is NOT the verdict `package.json` maps `check:issue-citations` to `node scripts/check-issue-citations.mjs --self-test` and nothing else -- the shape every credential-needing gate in this manifest uses (`check:pm-half-states`, `check:pm-closed-card-sweep`), because a live mode needs a board and a credential. Wiring that alias alone would have run the self-test twice and scanned nothing. So the lint step holds **two** commands, self-test first: ``` pnpm check:issue-citations && node scripts/check-issue-citations.mjs ``` The second spelling is lint.yml's documented gate-invocation idiom (`dispatch-gates.mjs`'s own header names it), and the first is what `check-self-test-wired` requires of every script CI runs. `check:merged-result` needed no such split: the manifest key already IS `node scripts/check-merged-result.mjs --self-test`, which is the whole gate. ## `package.json` is deliberately untouched No new manifest key was added. Both keys already exist and both are now named by `lint.yml`, so the wiring needs no manifest edit, and leaving the file alone keeps this PR textually disjoint from PR objectstack-ai#18414, which adds a key two lines from where a new one would have gone. ## The non-step changes: `issues: read` AND `pull-requests: read` >⚠️ Heading corrected by the dispatching PM at 2026-09-20T07:25Z: this section was written when the block > gained ONE scope. It now adds TWO — `pull-requests: read` followed from a measurement taken after > the body was written (patrol run 35495222460 censused **3628** unresolvable sites on a token without it, > run 35496169133 on the fixed head censused **2167**, the exact full-scope reading; the 1460 difference is > exactly the `resolves-as-pull-request` tally, because `GET /issues` omits pull requests without that scope). > ⛔ Nothing else in this body was altered. The `Lint & Repo Gates` job's `permissions:` block gains `issues: read`. An explicit `permissions:` block sets every unnamed scope to `none`; this board is public today, but a **blocking** gate whose transport depends on repository visibility is a gate that goes red on a settings change no file in this repo can assert. Read-only, one scope wide: the gate never writes an issue, a comment, a label or an assignee. ## Acceptance 2 -- both directions measured, on the CI side PR objectstack-ai#18223 measured the red/green pair on the **gate** side. This card asks for the same pair on the **CI** side. Two instruments, both here. **A. The commits on this branch ARE the CI-side ablation.** `test(ci): ABLATION LEG 1 of 2` adds one citation naming a number beyond this board's allocation frontier, in a declared surface (`packages/**/src/**/*.ts`, comment-prose projection). `ABLATION LEG 2 of 2` removes it and restores the file to the byte. The run on the first head is the red reading; the run on the final head is the green one. Both run identifiers are recorded in the dev report on the card. **B. The exact command the new step holds, ablated locally with disk evidence.** Three legs, run from a committed state, each restored with `git checkout HEAD -- path` and proven by hash: ``` target packages/types/src/index.ts HEAD blob 0235d4e leg 1 unresolvable marker 0 -> 1 blob 0235d4e -> 2ac9ba3 exit 2 RED finding: [never-issued] packages/types/src/index.ts:8 #999999 beyond the allocation frontier (19258) -- this number was never minted leg 2 restored marker 1 -> 0 blob back to 0235d4e exit 0 GREEN `git diff HEAD` empty after the restore leg 3 resolvable cites objectstack-ai#17512 instead exit 0 GREEN board: probed (1 citations), frontier objectstack-ai#19258, 2 numbers resolve ``` Leg 3 is the control on leg 2: a green produced by a live board read, not by a run that never asked the board anything. ## Acceptance 3 -- where the census reports, how often, who pays Written into the step itself, and repeated here: - **Where.** The patrol run's **step summary** and job log, plus one `::warning::` carrying the site count. Deliberately **not** the anchor issue: that body is owned end to end by `check-half-states.mjs`'s generator, and a second writer is how half a generated body goes stale. - **How often.** This workflow's schedule -- four times a day, six hours apart (`37 1,7,13,19`) -- plus any `workflow_dispatch`, plus the `pull_request` runs the paths filter admits. A row for `scripts/check-issue-citations.mjs` was added to that filter for the reason the file already gives for its two siblings: a step whose script can change without the trigger firing is a step whose PR-time proof is a coincidence. - **Who pays the 159 requests/run.** This repository's own `secrets.GITHUB_TOKEN` core quota -- the same 5,000/hour the job already draws the live sweep from. Four runs a day is roughly 636 requests/day, under half a percent of a single hour's allowance. No PAT and no cross-repo credential, per this file's own standing rule. The step is gated on `github.repository == 'objectstack-ai/objectstack'`, exactly as the closed-card sweep above it is, because `half-state-patrol.yml` is copied verbatim into sibling repos that do not carry this script -- a copy must skip the step, not fail on a missing file. It is placed **after** the anchor write, unlike the closed-card sweep: the anchor is this patrol's product, the job has a 15-minute timeout, and a report-only reading must never be able to starve it. It always exits 0. ## Acceptance 4 -- the 422 wall This diff touches `.github/workflows/**`, which is outside the PM seat's arming channel: the seat's `auto_merge` answers **HTTP 422** for this PR. **It merges by a human.** That is the same wall as PR objectstack-ai#18096 and objectstack-ai#18341, it is not a tool fault, and it must not be retried. No seat should undraft this PR or arm auto-merge on it. Note that `.github/workflows/**` is *not* on the `GOVERNED_SURFACES` register in `scripts/pm/check-governed-merges.mjs`, so the governed-merge machinery is not what holds this one -- the 422 is. ## Placement, and the three in-flight PRs on these files Read before editing: objectstack-ai#18414 (`lint.yml` + `package.json`, green, awaiting a human merge), objectstack-ai#19024 (`lint.yml`), objectstack-ai#19225 (`half-state-patrol.yml`, draft and frozen). Only additions here; no existing step was moved, renumbered or reformatted. - In `lint.yml` the two steps go **above** the `objectstack-ai#15149` step-name-quoting step, which keeps that step's own documented placement ("immediately above" the duration-unit-keys step) true and keeps the duration-unit-keys step last among the gates. objectstack-ai#18414 inserts at the control-byte guard (line ~411) and objectstack-ai#19024 edits the typecheck lanes (lines ~5173 and ~6104), so all three hunks are disjoint. - In `half-state-patrol.yml` the census step goes between the summary publish and the final fail step. objectstack-ai#19225 rewrites that file wholesale into a composite action and is frozen behind a `pm:blocked` card; a textual conflict there is expected and was accepted at dispatch. ## Measurement this PR does not relay The card's prose carries three disagreeing counts for the manifest census. Re-measured on this branch's base `0f42d36ff`, with a firing control and a dark control: ``` root manifest check:* keys 165 not named by any workflow (name or path) 2 -> check:merged-result, check:issue-citations ...and not named by any other script 2 firing control 'check:nul-bytes' in a wf true dark control 'check:zznotreal' in a wf false ``` Both unreached keys are the two this PR wires, so the reading after this lands is 0. ## Acceptance notes - `check-self-test-wired` admits a script when a workflow names it directly or through a root manifest alias, repo-wide rather than per workflow, so the patrol's census step needs no second self-test invocation: the lint step above already runs it. - No changeset: nothing any package publishes moves. Workflow files ship in no package's `files[]`. --- _Generated by [Claude Code](https://claude.ai/code/session_017ef78bLdybu3AffehKkhfk)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-elon-musk <elon-musk@objectstack.ai>
…are supported from protocol 16 onward (objectstack-ai#19302) Fixes objectstack-ai#19056 Clause-②: yes (narrowing) Maintainer ruling, 2026-09-18, verbatim and untranslated: > 升级只需要支持从 16.0版本开始。 `MIGRATION_SUPPORT_FLOOR` (`packages/spec/src/migrations/registry.ts`) moves **10 to 16**, and `step11`–`step16` retire with it. The direction was not re-argued. 「16.0」 reads as protocol **major 16** — the same unit as the constant, since `PROTOCOL_VERSION` is `17.0.0` while the package version is `17.4.0`. ## What landed | item | disposition | | --- | --- | | `MIGRATION_SUPPORT_FLOOR` | `10` to `16` | | `step11`–`step16` and their `MIGRATIONS_BY_MAJOR` registrations | deleted (328 lines) | | the 10 `entries/semantic/` files prefixed `11. 12. 13. 15. 16.` | deleted, then `gen:migration-registry` re-emitted the marked regions — never hand-edited between the markers | | replay fixtures below 16 | re-pointed at the oldest hop the chain still guarantees, floor to floor+1 | | `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` | **kept whole** — see below | Regenerated and committed: `spec-changes.json` and `docs/protocol-upgrade-guide.md`. (`api-surface-declarations/root.txt` was regenerated at the FIRST head; `main` then deleted that whole directory in `2277d1fcd1` (objectstack-ai#19024), so this head takes main's deletion and the roster is 15 artifacts.) `pnpm --filter @objectstack/spec check:generated` proved exactly those three stale and `--fix` regenerated only those three; the other 13 artifacts, `check:authorable-surface` included, were green throughout. ## The starred undecided item: both retirement tables are KEPT, and here is the proof The card asked whether `RETIRED_KEYS_BY_MAJOR` / `RETIRED_DEFS_BY_MAJOR` serve only the migration chain, or are also read as an independent retirement fact. **They are read as an independent fact, and the reading does not depend on the floor.** Three measurements: 1. **Neither table has a row under 11–16 at all.** Both literals carry exactly two keys, `17` and `18`; the `entries/retired-keys/` and `entries/retired-defs/` directories hold only `17.*` and `18.*` files (195 and 181 of them). The card's change-table row "rows 11–16" describes rows that do not exist, so there was nothing to delete even before the question of whether it would be safe. 2. **Structural.** `chain.ts` — the floor's only enforcement point — imports `MIGRATIONS_BY_MAJOR`, `MIGRATION_MAJORS` and `MIGRATION_SUPPORT_FLOOR`, and neither table. The tables' only non-test importer is `packages/spec/scripts/build-schemas.ts:116` (`check:authorable-surface`), which folds `Object.entries(...)` across **every** major into one set and never mentions `MIGRATION_SUPPORT_FLOOR` at all. The major is kept only to date a tombstone's aging clock. 3. **Ablation** (mutation and restore both proved on disk by `scripts/ablation-replace.mjs`). A row naming a **live** key was planted under major **11** — a major whose migration step this PR deletes — and `check:authorable-surface` still read it and judged it: ``` ❌ 1 RETIRED_KEYS_BY_MAJOR entr(ies) name a key that is still LIVE: - data/Object:access (registered at major 11) ``` Control: the same gate is green on the unmutated tree. Restore leg: `blob == HEAD (d4cb483)` and `git diff HEAD` empty. So a row below the floor is still the live proof that its retirement was declared, and dropping one errors nowhere at the moment it is dropped — exactly the silent loss objectstack-ai#6957 measured. Both facts are now pinned in `packages/spec/src/migrations/retired-tables-not-floor-scoped.test.ts` so the next floor move reads them before reaching for the delete key. The **D2 conversion registry** is untouched for the same class of reason: every rehydration seam replays the full conversion chain over stored `sys_metadata` rows, retired entries included, so the protocol-11/13/14/15 conversions keep converting rows at rest long after the source-side chain stops reaching them. What the floor removed is the D3 step that carried them — which is why they leave the chain-replay gate and nothing else. The test says so where a future reader will look. ## This is not a slimming change The card corrects its own filer and this PR keeps that correction. Measured on `src/migrations/registry.ts` at `e6a03e649` (17,718 lines): | block | lines | share | | --- | ---: | ---: | | `step11`–`step16` — what leaves | 328 | 1.9% | | `step17` | 4,699 | 26.5% | | `step18` | 7,565 | 42.7% | | registration map plus the two retirement tables | 5,077 | 28.7% | | file header | 49 | 0.3% | The value is a **narrowed support promise**: six permanently-replayable chains no longer have to be maintained, and the CI replay shrinks to the range the project actually promises — 10 of the 99 conversion fixtures leave the chain-replay gate because the chain no longer reaches the major that graduated them. ## Cost, and where it is written down `MIGRATION_SUPPORT_FLOOR` is a published export (`migrations/index.ts:23`). After the raise `applyMetaMigrations(doc, N)` throws `MigrationFloorError` for N in 10..15, so a consumer stopped at protocol 10–15 loses the one-command upgrade path; the remedy is to reach protocol 16 by another path first, which is what the refusal message already says. The literal TYPE of the constant also narrows from `10` to `16`. The changeset is **`minor`, not `major`** — this is the repo's own convention, not a judgement call about severity: `scripts/check-changeset-no-major.mjs` refuses a `major` bump for the duration of the launch window (every publishable package is in one Changesets `fixed` group, so one `major` promotes the whole stack), and it names the two mandatory carriers that replace the bump level meanwhile — the `**BREAKING**` banner and the ADR-0087 disposition. Both are in the changeset. Measured: `check-adr-0087-registration.mjs` reads it as `[BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription)` and exits 0. ## One deliberate out-of-surface fix `packages/cli/src/commands/migrate/meta.ts` advertised four examples — `--from 10`, `--from 10 --step`, `--from 11 --to 12`, `--from 10 --out …` — and this change makes **every one of them refuse**. That is a published defect this PR creates, in help text that ships in `@objectstack/cli`, so it is fixed here rather than filed. The examples are now derived from `MIGRATION_SUPPORT_FLOOR`, which closes the class instead of the instance: the next floor move cannot leave them advertising commands that throw. `packages/spec/scripts/build-upgrade-guide.ts` carried the same hard-coded `--from 10` and is derived the same way. ## Tests - `pnpm --filter @objectstack/spec test` — **500 files, 14,639 tests, all pass** (1 file / 1 test skipped, pre-existing). - `pnpm --filter @objectstack/spec typecheck` — **Done**, green (tsc, scripts tsconfig, and the test-layer debt gate). - `pnpm --filter @objectstack/spec check:generated` — **15** artifacts, all current. (15, not 16: `check:api-surface-declarations` left the roster when `main` deleted the directory in `2277d1fcd1`.) - Gates green: `check:migration-registry`, `check:authorable-surface`, `check:api-surface`, `check:api-surface-declarations`, `check:spec-changes`, `check:upgrade-guide`, `check:docs`, `check:published-files`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage`, `check:nul-bytes`, `check:merge-driver`, `check:spec-parsed-alias`, `check-adr-0087-registration`, `check-changeset-no-major`, `check-empty-changeset`, `check-closing-keyword-parity`, `check-undeclared-dep-imports`, `check-ci-filter-parity`, `check-spec-docblock-symbol-anchors`, `check-comment-mask-adoption`, `check-keyed-text-bounds`, `docs-audit/check-affected-docs`. - **NOT MEASURED**, with reasons: `packages/cli` typecheck and `check:type-check-debt` both need the whole workspace `dist/` closure built first. The debt gate says so itself and refuses with its own exit code 3 — `PREREQUISITE NOT MET`, explicitly "NOT a pass and NOT a finding". The closure build did not get a turn on this container's shared verify lock (`exit 99`, queue timeout after 9 minutes). The one error the cli typecheck reports inside the edited file is `TS2307 Cannot find module '@objectstack/metadata-protocol'` at `meta.ts:709` — an unbuilt-closure symptom on a line this PR does not touch, alongside 230 more of the same code across the package. CI builds the closure before this step and is the authority here. Test changes are re-pointings, not deletions: the replay fixtures, the composability gate and the manifest-composition cases all now read `MIGRATION_SUPPORT_FLOOR` rather than the literal `10`, so the next floor move re-points them instead of inviting another delete. Four assertions were added where the old ones could not see the move: a step at or below the floor is dead code and must not survive; `--from floor` must be a usable command (the floor+1 hop has to exist); every major the raise dropped is refused **by name**, with `fromMajor`, `floor` and the message; and the chain-replay gate states why a below-floor conversion leaves it, with an anti-vacuity case so the filter cannot empty the gate silently. ## Acceptance notes Out of scope for this PR, recorded rather than fixed: - **`skills/objectstack-upgrade/SKILL.md:103`** advertises `os migrate meta --from 10`, which refuses after this change. `skills/**` is a Tier H governed surface and one governed path forks the whole PR to Tier H, so fixing it here would hold this diff for the maintainer's hand. Deliberately left; a one-line docs-only change. This is a real conflict between two binding rules (fix what this change falsifies, versus do not fork a code PR to Tier H) and it is named here rather than quietly resolved. - **`packages/metadata-core/src/protocol-handshake.ts:252`** builds `objectstack migrate meta --from TARGET` from the incompatible package's own declared target major, with no clamp to the floor, so a package targeting a major below the floor is handed a command that throws. Pre-existing — its own test already pins a `--from 6` answer — and widened from "below 10" to "below 16" by this change. Reproducible defect; worth a card. - **The card's blast-radius table over-counts `step18`.** It lists 12,041 lines; measured, the `step18` block is **7,565**. The 12,041 figure runs from `step18` to end of file and therefore absorbs `MIGRATIONS_BY_MAJOR` plus both retirement tables — about 5,077 lines, i.e. precisely the tables the same card says must not be deleted. The card's conclusion is unaffected and stands. Noted, not filed. - `docs/adr/0087-...md:181,426` and `content/docs/releases/v15.mdx:520` narrate `migrate meta --from 10` as history. Both are correct as history and both are on surfaces a code PR must not edit (governed; release-owned). Noted, not filed. Carrier: none — no open PR touches either file. --- > ⭐ **Seat correction, 2026-09-20T13:53Z — two claims above were true at the FIRST head and are false at this one.** Both are edited in place: the regenerated-file list and the `check:generated` artifact count. The cause is outside this PR — `main` deleted `packages/spec/api-surface-declarations/` in `2277d1fcd1` (objectstack-ai#19024, ruling C) at 12:52Z, which took that gate off the roster. > > The implementer flagged both rather than PATCHing this body itself: its contract writes a PR body once, on the call that opens the PR, and ⛔ never edits it. That is the correct division, and this edit is the seat's half of it. > >⚠️ **One requirement of the contract review (`5749969134`, ①-7) was MEASURED FALSE and deliberately not carried out.** The review asked that `api-surface-signatures.json` be regenerated "in its restored hash form … because your `10`→`16` literal moves a declaration signature". It does not: that artifact records a hash of each `defineX` **factory** signature (27 of them), and `MIGRATION_SUPPORT_FLOOR` is a plain `const`, so it is not in that artifact at all. The snapshot that WOULD have carried it is `api-surface-declarations/root.txt` — the very file `2277d1fcd1` removed. The implementer ran the generator anyway rather than argue from reading: `gen:api-surface` exit 0, `git status --porcelain` **empty** — identical bytes. ⇒ the prediction was about an artifact this repository no longer has. > >⚠️ The review also said **rebase**; the implementer **merged** instead and said so, citing `AGENTS.md` (⛔ no rebase or force-update of a pushed, reviewed branch) and `scripts/pm/os-regen-merge.sh`, whose own header prescribes `git merge origin/main` and ⛔ `never rebase / force-push` — because the os-regen merge driver can drop one side at exit 0 and only that script's ORDER exposes it. The seat accepts both departures: each is measured, named, and backed by in-repo authority. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…es option, and a pin reads the ruleset (objectstack-ai#19448) Fixes objectstack-ai#19344 Clause-②: no Item 6 of the maintainer's ratified directive — verbatim 「1 2 3 4 5 6 8」 (record `5750077963`, relayed onto this card as comment `5750078192`) — narrowed by the director's pointer `5749924838`: route **(a)** was taken in person, PR objectstack-ai#19024 merged by bypass (`merged_by` os-zhuang, squash `2277d1f`). What remained is exactly what this PR does — the guard's remedy sentences name a path that EXISTS on ruleset `main`, and a pin fails when they name one it does not offer. ## The defect Ruleset `main` (id 12119582, enforcement `active`, target the default branch) carries a `merge_queue` rule and lists `Governed Surface Queue Guard` among its seven required contexts. So the only Merge button an ordinary account is offered is "Merge when ready" = enqueue, and the size limb then refuses the queued group. The remedy prescribed 人工直合 — "the maintainer's own click" — without naming WHICH click, and the only click that is not an enqueue is the Merge button's **bypass-rules** option, offered only while the ruleset configures a bypass actor. With none configured, the remedy named a terminal nobody could reach: PR objectstack-ai#19024 was enqueued three times and refused three times. ## Before / after — the four sentences Each keeps 人工直合 as the NAME of the act (the 2026-09-18 ruling stands untouched); what changes is the description of how the act is reached. **1. The header sentence (`:410`, the size limb's own contract)** Before: ``` * maintainer's own click (人工直合). An authorized APPROVED review lifts a ``` After: the same opener, then — `main` mandates the queue and requires this check, so the only Merge that is not an enqueue is the Merge button's BYPASS-RULES option, offered only while the ruleset configures a bypass actor; while none was, the remedy named a terminal nobody could reach and PR objectstack-ai#19024 was enqueued and refused three times; that it IS offered is a ruleset fact the pin reads. **2. The governed limb's early warning (`renderGuardVerdict`, Tier H block)** Before: ``` Unapproved, the maintainer's own direct merge (人工直合) is the only landing this pull request has. ``` After: ``` Unapproved, the maintainer's own direct merge (人工直合) is the only landing this pull request has, and it IS the Merge button's bypass-rules option — offered only while ruleset `main` configures a bypass actor (objectstack-ai#19344). ``` **3. The governed limb's refusal (`renderGuardVerdict`, remedy item 2)** — same replacement, plus "the audit log records it". **4. The SIZE limb's refusal (`renderSizeVerdict`, remedy item 2)** — rendered on the objectstack-ai#19024 shape: ``` 2. Then a HUMAN MERGE — the same terminal a governed diff has: ACCEPT on the card, `needs-user-decision` on the PR, a final 维护者速读, review requested from GOVERNED_APPROVERS (os-zhuang, hotlong); the maintainer's own click lands it (人工直合) — and that click is the Merge button's bypass-rules option, offered only while ruleset `main` configures a bypass actor — ⛔ NOT a second Merge button: `main` mandates the queue and requires this check, so with none configured every re-enqueue comes back here (objectstack-ai#19344). The audit log records the bypass and `check-governed-merges` lists such a landing on size. ``` ## The ruleset reading this seat measured `GET /repos/objectstack-ai/objectstack/rulesets/12119582`, with this seat's token, on the day this PR was written: **HTTP 200**, and the response carries **no `bypass_actors` key at all** — not `null`, absent. The keys it does return are `id name target source_type source enforcement conditions rules node_id created_at updated_at current_user_can_bypass _links`, and `current_user_can_bypass` reads `"never"`. That is a different fact and is recorded beside it: it says THIS token is not itself a bypass actor, which is true of every agent seat and says nothing about whether the ruleset configures one for the maintainer. The director's earlier pointer read the field as `null`; this seat's read gets no key. Both are "cannot conclude", and the pin treats them differently only in what it prints. `check-required-contexts.mjs`'s measured header is the authority on why: the ruleset endpoints answer 200 for `metadata=read`, but the bypass roster is an `administration` field, and `administration` is not one of the 17 permissions a workflow may grant its `GITHUB_TOKEN`. So no token this repository's CI can hold will ever read `bypass_actors`. ## The pin, and its three verdicts One new battery in `--self-test`, five cases, driving two new pure exports (`bypassActorReading`, `remedyPathVerdict`) over a frozen copy of the measured response: | `bypass_actors` as read | reading | pin | |:--|:--|:--| | key absent (this seat's and every Actions token's answer) | `unreadable` | **PASSES**, printing `bypass_actors: unreadable with this token (the key is absent); current_user_can_bypass: "never"` | | present and `[]` or `null` | `not-offered` | **REDS** — the remedy names a path the ruleset does not offer | | present with at least one actor | `offered` | PASSES | Two ways to red, not one: the ruleset is READ to offer none, **or** the remedy stops naming a path at all — which is the defect this card filed, and a pin that only checked the first would sit green through it. The self-test prints the reading on every run, so "unreadable" is never a silent pass. ⛔ It never asserts a path from a field it did not read, and ⛔ it never reds CI on a permission difference. ## Recorded, not live — the four axes The card allowed either a live read or a recorded fixture. **Recorded, and the live read deliberately not added.** - **实际业务需求** — measured, not supposed: `bypass_actors` is unreadable to this seat's token AND to any Actions token (above). A live read wired into this self-test would therefore answer `unreadable` on every CI run in existence — it would assert nothing, on every run, while adding a network call. The real need is that the remedy sentence names a reachable path; the only party who can verify reachability is the maintainer, and their verification already happened (the objectstack-ai#19024 bypass merge). A recorded reading is what that evidence looks like in this file. - **项目长远合理性** — this self-test is the **first step** of the required `Governed Surface Queue Guard` job, run under `bash -e` as the precondition for trusting the guard, and its own usage line declares it `offline, no network, no git`. Making a merge precondition depend on api.github.com reachability and on a token's permission tier is the permanently-red-gate shape this repo has already retired. The precedent is in-repo and exact: `check-required-contexts.mjs` keeps a frozen `RULESET_SNAPSHOT` in its self-test and leaves the live diff to a report-only mode that never runs in CI, for the same structural reason. - **防 AI 写代码犯错** — a live read is the lenient-consumer shape here: it passes for every token that cannot see the field, so the assertion would be phantom and the next author would read green as "the path is reachable". The recorded form makes the claim declared and falsifiable — change the remedy, and the pin demands a reading that offers the path; the reading is printed rather than swallowed. - **创业阶段不扩散需求** — one battery, two pure exports, one frozen object, no new entry point, no new script, no new workflow, no widened token scope. Net **+60 lines**, exactly the budget. The fixture's cost is drift, and it is bounded on purpose: it carries its provenance in the comment above it (endpoint, id, read date, token class), its reading is printed on every self-test run rather than asserted silently, and the field it records is one no CI token can re-read anyway — so a live read would not have detected drift either. ## Evidence - `node scripts/pm/check-governed-queue-guard.mjs --self-test` :: exit 0 — **301 cases** (296 before; +5 is exactly the new battery), and the run prints `ℹ objectstack-ai#19344 ruleset reading — bypass_actors: unreadable with this token (the key is absent); current_user_can_bypass: "never"`. - **Ablation** (`scripts/ablation-replace.mjs`, wrap mode, on the committed state): anchor `click is the Merge button's bypass-rules option` replaced in `renderSizeVerdict` — on-disk proof `anchor 1 -> 0, blob 5b75964 -> e6ee4e00f482` — self-test went **red, 3 of 301**, naming `all-four-remedies-NAME-the-bypass-rules-option…`, `a-field-this-token-cannot-see-is-UNREADABLE-and-PASSES…` and `one-configured-bypass-actor-makes-the-named-path-REACHABLE…`. Restore proven byte-identical: `blob == HEAD (5b75964)` and `git diff HEAD` empty. The direction is the expected one (red), and it reds via BOTH limbs of the contract, which is what "two ways to red" means. - **Derived gate union** (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, no paths, 31 families): 30 run with exit captured before any pipe, **all exit 0**; `pnpm check:pm-dispatch-gates` was still running when this PR was opened and is reported in the card comment. `--ran` reconciliation: 31 derived, 30 accounted with real exit codes, 0 NOT-MEASURED among them. - `node scripts/pm/check-governed-merges.mjs --test scripts/pm/check-governed-queue-guard.mjs` :: exit 0 — **NOT governed**; ordinary queue landing applies. `--pr 19024` :: exit 3 — the size-decided landing is listed, which is the recognition the remedy text now points at. - PM mechanism assumptions: (1) confirmed — the three sentences read as described on `origin/main`; (2) PR objectstack-ai#19379's region `:268–:294` is untouched by this diff; (3) confirmed — the self-test is wired at `.github/workflows/governed-surface-guard.yml`, extended in place, no second entry point; (4) confirmed above. ## Acceptance notes - The ruleset response carries `current_user_can_bypass`, which an ordinary token CAN read and which directly answers "is the bypass option offered to the account asking". Nothing in this repo probes it live; it is recorded in this fixture only. Observation, not filed — it is a capability nobody has pulled on, and the four-axis call above is not to add a live probe to a required precondition. - `check-required-contexts.mjs`'s `RULESET_SNAPSHOT` records the 2026-08-18 reading with **six** required contexts; the live ruleset now carries **seven** (`Governed Surface Queue Guard` joined since). That file's assertions are deliberately written on the SHAPE, not on membership, and its header says so, so nothing is wrong — but the snapshot is a frozen historical reading and reads at a glance like a current one. Observation, not filed. - ⛔ Not touched, per the card's own "Not this card": the 5000-line threshold (it lives in `check-governed-merges.mjs`, imported here), the required-context set, the 「THIRD leg」 header at `:268–:294` (PR objectstack-ai#19379's region), and any workflow. --- _Generated by [Claude Code](https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi)_ Co-authored-by: Claude <noreply@anthropic.com>
…arries UNIQUE_VIOLATION, and the packaged-set lock answers first (objectstack-ai#19437) Fixes objectstack-ai#19307 Clause-②: yes The data door's duplicate-name refusal on `sys_permission_set` had two halves, both reproduced on today's head (`c33707933`) before any edit and re-measured after, on `examples/app-showcase` with a seeded admin over a cookie session. ## The two halves **1. No machine-readable `code`.** The insert leg threw a bare `Error` carrying `.status = 409` and no `.code`. The flat `{ error, code }` responder invents nothing for a producer that declared nothing, so the caller got prose — against ADR-0112's 2026-08-17 amendment (objectstack-ai#9232), under which the flat door carries the closed member too. **2. ⭐ It ran BEFORE `assertPermissionSetNotPackageDeclared`.** A package-declared set has a projected row, so its name is duplicate AND locked at once. The admin who opens the Clone dialog on a packaged set and types the base set's own name — the single most likely thing to type — got `already exists`, which names no remedy, and never reached `NOT_OVERRIDABLE`, which names the clone path. ## Live readings — five legs, same script, before and after Script: `POST /api/v1/data/sys_permission_set` as the seeded admin; full text in the report comment on objectstack-ai#19307. | leg | BEFORE (`c33707933`) | AFTER (this branch, built) | |:--|:--|:--| | 1. duplicate name = **package-declared** `showcase_manager` | `409` · `{"error":"[Security] permission set 'showcase_manager' already exists","object":"sys_permission_set"}` — **no `code` key** | `403` · `"code":"NOT_OVERRIDABLE"`, message: *"…Choose a different name for your set, or clone 'showcase_manager' (the "Clone" action…)"* | | 2. create a NON-packaged set | `201` | `201` (unchanged) | | 3. ⚖️ negative control — duplicate of that NON-packaged set | `409` · no `code` | `409` · `"code":"UNIQUE_VIOLATION"`, message byte-identical | | 4. ⚖️ contrast control — unauthenticated `PATCH`, same resource | `401` · `"code":"UNAUTHENTICATED"` | `401` · `"code":"UNAUTHENTICATED"` (unchanged) | | 5. ⚖️ contrast control — `PATCH` the packaged set (non-duplicate route) | `403` · `"code":"NOT_OVERRIDABLE"` | unchanged | Legs 4 and 5 are what make legs 1 and 3 readings rather than constants: the flat door already varied its `code` by path, so the absence was this producer's and never the door's. ## Why `UNIQUE_VIOLATION`, reused and not minted `sys_permission_set` declares `{ fields: ['name'], unique: 'organization' }`, and the reading recorded on that index itself (objectstack-ai#8554) is `org_yi 409 UNIQUE_VIOLATION`. So the platform **already** answers this exact collision with this exact envelope whenever the index catches it instead of the projection's pre-check. A second spelling here would make one condition answer two envelopes depending only on which layer got there first — the drift `@objectstack/rest` and `@objectstack/driver-memory` deliberately registered the SAME code to avoid. `RESOURCE_CONFLICT` (the standard member 409 derives from) is what the door supplies for a producer that named no condition; using it would be that second spelling. ## The `packages/spec` touch is one provenance row `check:error-code-provenance` recognises `objlit`, `assign` and `*_CODE` `constdef` stamp sites and states in its own bounds line that it is **blind to class fields**. Written as a class-field literal, this package would have become an unlisted EMITTER of a registered code with every gate in the repo green — the invisibility the ledger header names ("no admission rule checks WHO emits"), found by hand three times already (objectstack-ai#7504 / objectstack-ai#13254 / objectstack-ai#13353). So the code is stamped through an exported `PERMISSION_SET_NAME_CONFLICT_CODE`, which puts the emitter inside the gate's field of view, and the ledger gains the matching row under `@objectstack/plugin-security`. Measured red then green, in that order: - class-field spelling, no ledger row: gate **exit 0** — it never saw the stamp; - `*_CODE` constant, no ledger row: gate **exit 1** — *"@objectstack/plugin-security stamps 'UNIQUE_VIOLATION' (constdef) at packages/plugins/plugin-security/src/errors.ts:372 — not listed under its own owner key"*; - `*_CODE` constant + the row: gate **exit 0**, 339 stamp sites, 322 listed. **It is provenance, not identity, and that is measured rather than asserted.** The deduped ledger union is **282 distinct codes before and 282 after, added [] / removed []** — `UNIQUE_VIOLATION` was already a member under two other packages. `pnpm --filter @objectstack/spec check:generated` reports all 15 generated artifacts up to date, which supports ONE claim only — no regeneration is MISSING. ⛔ It does NOT show the published TYPE is unchanged, and here it is not: `ERROR_CODE_LEDGER` is exported `as const satisfies`, so this row moves `typeof ERROR_CODE_LEDGER['@objectstack/plugin-security']` from a 3-tuple to a 4-tuple. `check:api-surface` stayed GREEN THROUGH that change, because `api-surface/api.json` records the symbol name (`ERROR_CODE_LEDGER (const)`) and no signature, `api-surface-signatures.json` covers 27 `define*` helpers and not this const, and `api-surface-declarations/` was withdrawn by objectstack-ai#19024 and does not exist on this base. That is why this PR declares `Clause-②: yes` and grades `@objectstack/spec` `minor`.⚠️ `check-widening-tells` still fires **T4** on that line with `Clause-②: no` (exit 4), because the matcher cannot see the dedupe — it reads any new registry entry as an acceptance-set growth. The measurement above is the evidence it is false here. Reported for the owning seat rather than repaired in this PR; the declaration line is copied verbatim from the claim and is not mine to move. ## One corner moved with the order, declared not incidental An ordinary duplicate attempted while **no artifact source can answer** now takes the lock's fail-closed `unknown` refusal — `403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry once the metadata layer is readable") — instead of the 409. Both are refusals and neither writes; case 5 of the new suite pins it so a reader finds a decision rather than an accident. ## Tests `packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts` — five cases: the ordinary duplicate's envelope (`code` + `status` + both status spellings, never a bare "it threw" — the unfixed producer threw too), the packaged-set case answering the **lock** (proved by the lock's own message and by `saves.length === 0`, since ADR-0005's tier gate answers the same code with a different message), the negative control that ordinary duplicates WHOSE PROVENANCE RESOLVES did not become `NOT_OVERRIDABLE` (the corner above is the case that qualifier excludes), the happy path, and the fail-closed corner. **Reverse verification — two ablations, each proved on disk and restored byte-identical to `HEAD`:** - **A — put the guard back behind the duplicate check.** Mutation landed (anchor 1 → 0, marker 0 → 1, blob `fed1af15857f` → `3d92cb95990f`); suite **2 failed / 3 passed** — cases 2 and 5, the two ordering-dependent ones, and *only* those. Restored: `git diff HEAD` empty, blob back to `fed1af15857f`. - **B — drop the `code` stamp.** Mutation landed (target 1 → 0, marker 0 → 1); suite **2 failed / 3 passed** — cases 1 and 3, `expected undefined to be 'UNIQUE_VIOLATION'`. Restored: blob back to `7b0914b1749a`. Both legs ran under a `trap ... EXIT INT TERM` with absolute paths, restored through `git checkout HEAD -- path`, and verified by `git hash-object` against the `HEAD` blob rather than by an exit code. ## Verification Run at final head `72d68b905` unless stated. - `pnpm --filter @objectstack/plugin-security test` — **116 files / 2240 tests passed**; `typecheck` OK (test layer 0 files / 0 errors). - `pnpm --filter @objectstack/spec test` — **505 files / 14752 tests passed**; `typecheck` OK; `check:generated` — all 15 artifacts up to date. - `pnpm lint` — the **whole repo**, `eslint . --no-inline-config`, **exit 0**. Not a narrowed run, so no narrowing needs defending. - `scripts/pm/dispatch-gates.mjs --ran` — **98 derived families, 98 run, 0 NOT-MEASURED, 0 UNRUN**, each with a recorded exit code, all 0. Two of them were genuinely red mid-flight and are green only because the fix landed: `check:engine-double-contract` (the new double's `update` now opens with `assertEngineUpdateDispatch`, and its pinned seams are registered) and `check:objectql-double-limit` (the `find` double now holds the caller's bound by presence). - Control-character self-scan over every touched file: clean. Builds and test runs went through `scripts/pm/os-verify-lock.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…rd package body stages, and stop the record under-reporting functions (objectstack-ai#19373) Fixes objectstack-ai#17518 Clause-②: yes Executes ruling **A′** — decision batch objectstack-ai#192 item 3, comment 5748934194, maintainer 「192 同意」. Its two steps, its refusals (A and B) and its fences are followed as written; every place where the tree made me read the ruling rather than transcribe it is called out below. Base of every reading in this body: regeneration commit `96dd3549ff6`, the head of the SIXTH merge. >⚠️ **The readings below were brought to this head by the seat, not by the round that first wrote them.** Two merge rounds have run since the first draft. Each figure corrected here is named in the correcting round's own report on card objectstack-ai#17518 — comment 5750725852 for the first, 5750987577 for the second — and the seat re-verified the head, the regenerated index and mergeability itself before editing. Anything not listed in those two reports is the original round's reading, unchanged. ## The confidence gap the ruling asked me to close first 「whether `effect` is required or defaulted on the declaration schema — read it, ⛔ do not mint a value」 **Defaulted.** `FlowFunctionDeclarationSchema.effect` is `FlowFunctionEffectSchema.default(DEFAULT_FLOW_FUNCTION_EFFECT)` where that constant is `'pure'` (`automation/flow-function.zod.ts`). Measured, not read off the source alone: `FlowFunctionLoweredDeclarationSchema.safeParse({ handler: 'x' })` succeeds and yields `{ handler: 'x', effect: 'pure' }`. The array member of `functions` states `FlowFunctionEffectSchema.optional()` with **no** default, so the two forms differ and neither is restated anywhere in this diff — each JSON stage inherits its form's own optionality by deriving from it. That reading is what the producer writes: the bare-callable normalisation uses `DEFAULT_FLOW_FUNCTION_EFFECT` and the array form gets nothing. ## What landed **`packages/spec/src/automation/flow-function.zod.ts`** — `FlowFunctionLoweredDeclarationSchema` is exported (step 1), with its `FlowFunctionLoweredDeclaration` / `…Parsed` aliases. It was a module-local `const`, and `automation/index.ts`'s `export *` only re-exports what is already exported. **`packages/spec/src/stack.zod.ts`** — two new bodies **beside** `AssembledPackageBodySchema`: - `ArtifactStagePackageBodySchema` — the on-disk artifact stage. `functions` entries are the lowered spellings, `hooks[].handler` is a string. - `RecordStagePackageBodySchema` — the registry record stage: literally `ArtifactStagePackageBodySchema.extend({ functions: … })` with `functions[].handler` optional in both the map-record form and the array form, and nothing else. `AssembledPackageBodySchema`, `composeStacks` and the `cannot drift` invariant are ⛔ untouched: those callables are live on the stage the assembled body declares itself for, and narrowing it would refuse a published composition function's own output. Both new schemas carry the same structural `z.ZodType` annotation as the assembled body, for the two reasons recorded there (TS7056; a named alias turning `stack.zod` into a shared chunk). **`packages/spec/src/api/package-api.zod.ts`** — the installed-package row's `manifest` is rebound to the record stage (step 1). The `z.unknown()` override and the docblock defending it are gone, and the sentence that ruling A step 5 assigns to this edit is corrected in place: those two members are **not** why `ArtifactPackageSchema` and `ObjectStackDefinitionSchema` publish no JSON Schema — `src/stack.zod.ts` is not one of the subpath namespaces `build-schemas.ts` walks, so neither is ever reached by the emit loop. **`packages/objectql/src/registry.ts`** — step 2. `withDeclaredFunctionEntries` rewrites a bare callable `functions` map entry to `{ handler, effect: DEFAULT_FLOW_FUNCTION_EFFECT }` at the assembly boundary, before `toRecordManifest` runs. `toRecordManifest`'s structural rule is ⛔ untouched and no key is special-cased inside the projection; the two spellings are simply made structurally equal ahead of it. ⛔ No ref is minted, ⛔ no entry is dropped. The caller's manifest is never mutated and a copy is made only when an entry really needed rewriting. ## Two places where I read the ruling rather than transcribed it — both stated so they can be overruled 1. **「`functions` entries the lowered declaration」 is implemented as BOTH lowered members of `FlowFunctionEntrySchema`**, not only the record one. `objectstack build` emits `{ myFn: 'myFn' }` for a bare entry and `{ myFn: { handler: 'myFn', effect } }` for a declared one, so a stage admitting only the record form would refuse artifacts this repo really writes — the failure mode that withdrew letter B, one key across. Ruling A′'s own step-4 control names both shapes (「a string and a lowered record」). Measured: the artifact stage accepts a body carrying one of each. 2. **The array member is transcribed, not derived.** `functions`' array branch is declared inline inside the assembled body's own shape, and narrowing it in place is the one thing this pair may not do. The transcription's drift is guarded instead: `stack-json-stage-package-body.test.ts` pins the authoring array entry's key set equal to both JSON stages', so a key added there and not here reddens by name. ## Acceptance, as ruling A′ lists it | criterion | result | |---|---| | both bodies convert under `z.toJSONSchema` (self-test over the whole body) | **YES** / **YES**; control: the assembled body still **NO** (`Function types cannot be represented in JSON Schema`); probe controls lit `z.string()` YES, dark `z.object({a: z.function()})` NO | | the showcase-shaped manifest (`config.ts:244-249`) reports **2** functions on the `GET /packages` row, the bare one as a handler-less declaration | **2**: `{"summarizeCompletedTask":{"effect":"pure"},"sweepProjectHealth":{"effect":"writes"}}`, driven through the real `SchemaRegistry.installPackage` | | `hooks` unchanged | unchanged: an inline handler is dropped (the key is optional and admits that), a string handler survives verbatim. The array `functions` form also keeps its entry: `[{"name":"syncBilling","effect":"writes"}]` | | `AssembledPackageBodySchema` / `composeStacks` / the invariant untouched | untouched — no edit in those regions; `assembled-package-body.test.ts` and `compose-stacks-manifest-preserve.test.ts` stay green | | the two `noted, not filed` corrections in the same edit | baseline reason line: made TRUE by step 1 rather than reworded — `automation/FlowFunctionLoweredDeclaration` is now in `json-schema.manifest/automation.json`, so 「the lowered record … publishes normally」 is now a fact. `package-api.zod.ts` docblock last sentence: corrected in place, see above | Stage separation, measured rather than asserted: the record stage accepts the handler-less declaration and the **artifact** stage refuses it; the assembled body accepts a live callable and **both** JSON stages refuse it; both JSON stages still refuse an authoring glob and an unknown key (`namesapce`). So the two keys moved from `unknown` to a declaration, and nothing else moved. ## Reverse verification — two ablations, each restored with proof Both ran against committed code, each with a `trap` restore, an on-disk landing proof (anchor `grep -c` before/after plus a blob-hash change) and a restore proof (`git hash-object` back to the HEAD blob, `git diff HEAD` empty). - **A1 — remove the producer normalisation** (`toRecordManifest(withDeclaredFunctionEntries(manifest))` → `toRecordManifest(manifest)`; anchor 1→0, injected 1, blob `b0af60d7…` → `17b7c93c…`): `registry-package-manifest-serializable.test.ts` goes **1 failed / 15 passed**, naming the exact defect — `expected [ 'sweepProjectHealth' ] to deeply equal [ 'summarizeCompletedTask', …(1) ]`. Restored blob `b0af60d7…`, diff empty. - **A3 — collapse the record stage into the artifact stage** (`jsonStageFunctionsKey(true)` → `(false)`; anchor 1→0, injected 2, blob `60c13b43…` → `822bed8e…`): **2 failed / 79 passed** across two files — `record accepts the handler-less declaration; ⛔ the ARTIFACT stage refuses it` and `parses a row carrying the residual the projection really produces`. So the one-key difference that IS the fourth stage is load-bearing in both packages' pins. Restored blob `60c13b43…`, diff empty. No ablation is offered for 「both bodies convert」: that claim already carries its discriminating control inside the same test file (the assembled body must NOT convert), which is a lit/dark pair rather than an assertion about itself. ## Tests and gates All through `scripts/pm/os-verify-lock.sh` with `OS_VERIFY_LOCK_SLOT=issue-17518`, verdicts read from the wrapper's own `VERDICT command-exit` line and never a bare `$?`; every exit code captured before any pipe. Wall-clock figures in the logs are SHARED-BOX seconds. - `pnpm --filter @objectstack/spec test` — **513 files / 14971 tests passed, 1 todo** — the FULL suite, re-run on this head because the sixth merge carried 128 commits of base movement including breaking spec changes - `pnpm --filter @objectstack/objectql test` — **303 files / 5057 tests passed** - `pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2` over the package-door / artifact population, enumerated by a name match on `packages/runtime` for `package` or `artifact` so the population is reproducible — **39 files / 512 tests passed**.⚠️ The first attempt exited 1 in 2 seconds and is recorded as NOT a red: the paths were repo-root-relative while `pnpm exec` runs at the package root, and the repo's own guard said so in words (`FILTER SELECTED NOTHING — 39 of the 39 path(s) you named will run no tests`). Re-run with package-relative paths for the reading above. - `pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck` — exit 0; both test layers compile (spec **53 files / 257 errors / 142 pins**; objectql **40 / 234 / 65**, unchanged).⚠️ The spec ledger moved from 54 / 259 / 144 by main's objectstack-ai#19364 arriving in a merge, ⛔ not by this PR. - `pnpm --filter @objectstack/spec --filter @objectstack/objectql typecheck` — both exit 0 on this head; the debt ledgers held shrink-only (spec 53 files / 257 errors / 142 pinned signatures; objectql 40 / 234 / 65). - `pnpm --filter @objectstack/spec build` exit 0 (34/34 declared `.d.ts` present, `check-dts-references` resolved 378/378), and the whole `@objectstack/runtime` dependency closure was rebuilt first, so nothing below read a dist stale against 128 commits of main. **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived from this tree, every command run with its exit code written to a file, reconciled with `--ran`: **116 derived, 114 run, 2 NOT-MEASURED, 0 UNRUN**, and the tool's own verdict line says so. **113 exit 0.** The two NOT-MEASURED are the tool's DERIVED classification of an exit 3; a third measured nothing too, and the tool cannot see it because its refusal code is 2. ⛔ None of the three is a finding: - `check:dual-build-cjs-loads` — exit **3**, its own `PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured` (66 packages have no `dist`; it wants a whole-repo build). - `check:type-check-debt` — exit **3**, same shape, same wording, wants the full package closure built. - `check-engine-split-ratio --days 90` — exit **2**, refuses on a shallow clone whose oldest visible commit sits inside the 90-day window. It says a ratio derived there would be 「real, plausible and WRONG」. A fourth, `check:skill-examples`, first exited 1 on an unbuilt `packages/client-react`; after building that package it re-runs **green** — 258 prose examples type-check across 3 surfaces. Both readings are stated here, and the reconciliation record carries ONE of them — the green re-run — because the tool flags a doubly-recorded family and says to make the record state one thing. The re-derivation on the final head yields **116** families: `check:api-surface-declarations` is gone (retired upstream by objectstack-ai#19024 mid-round) and `check:gitlink-declared` is new, run green. No family is left unrun. Ratchet families re-run after the last merge, on `96dd3549ff6`: `check:generated` (all 15 artifacts up to date), `check:api-surface`, `check:authorable-surface`, `check:export-origins`, `check:declaration-map`, `check:docs`, `check:skill-refs`, `check:entry-nameability`, `check:dual-source-exports`, `check:spec-changes`, `check:spec-parsed-alias`, `check:published-files`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:test-source-alias`, `check:type-check-coverage` — all exit 0. Control characters: `grep -naP` over every file I hand-edited returns nothing (exit 1). ## Generated artefacts in this diff, and why each moved - `json-schema.manifest/automation.json`, `authorable-surface/automation.json`, `authorable-defaults/automation.json`, `api-surface/*`, `export-origins/*`, `declaration-map/automation.json`, `content/docs/references/**` — the new exports, regenerated by the package's own `gen:` scripts. `authorable-defaults` records `automation/FlowFunctionLoweredDeclaration:effect = "pure"`, which is the confidence-gap reading in ledger form. - `packages/spec/dropped-refinements.baseline.json` — four `api/*` entries each gain one site (`…manifest.hooks.element.object`), counts 569 → 573. Cause: the record stage **declares** `hooks` where `z.unknown()` declared nothing, so `HookSchema`'s `object` refinement now reaches the runtime and not the published file. The ledger is hand-edited by design and the build printed the exact delta. - `skills/objectstack-platform/references/_index.md` — one generated line listing `stack.zod.ts`'s exports. ## `skills/**` readings, and the landing tier This diff touches `skills/objectstack-platform/references/_index.md`, so the PR is **governed, Tier H** on its file list. ⛔ It stays a draft and no AI seat merges, queues or arms auto-merge on it. Both readings the skills rule requires, at merge base `c334ba0f3a6`: - **changed file, whole file**: 41 lines before, 41 after — net **0**. The diff is one regenerated line. - **package total (sum of every `SKILL.md`)**: 6145 before, 6145 after — net **0**. `node scripts/check-skills-token-ratchet.mjs` exits 0 and classifies this file as **generator-owned (measured, not ratcheted)**, so no authored ceiling is charged. ## Clause ②, and the changeset is not one package's `Clause-②: yes`, and two changesets because two published packages move: - `@objectstack/spec` — **minor**. New exports, and the two installed-package responses move from `z.unknown()` on `functions` / `hooks` to declared JSON shapes. That is a narrowing on a published declaration; what it does NOT withdraw is measured, on real producers: the showcase shape, the array form and the already-lowered body an artifact boot installs all parse. - `@objectstack/objectql` — **patch**. `GET /packages` reports functions it previously dropped. No API is added or removed; a read door stops under-reporting. Grade it up if a payload gaining entries reads as minor to the reviewer. ## Serial and merge state, re-taken by this seat Changed-file map re-taken first-hand over all **33** open PRs (271 file rows) rather than inherited. LIT control `packages/spec/src/ui/action-params.zod.ts` resolves to objectstack-ai#19315; DARK control `packages/spec/src/zzz-no-such.zod.ts` resolves to nothing. - `packages/spec/src/automation/flow-function.zod.ts`, `packages/spec/src/api/package-api.zod.ts`, `packages/objectql/src/registry.ts` — **free**. - `packages/spec/src/stack.zod.ts` — held by objectstack-ai#18482, objectstack-ai#19147, objectstack-ai#19314, all below A′'s region. objectstack-ai#19147 landed during this round and merged cleanly here (its `stack.zod.ts` hunk is a comment). - `packages/spec/dropped-refinements.baseline.json` — also written by objectstack-ai#19147 (landed, resolved here) and by the still-open **objectstack-ai#19335**, which rewrites the same `measured` header and adds entries. That is a line-level contention on a ledger whose correct value is recomputable: whoever lands second re-runs `pnpm --filter @objectstack/spec build` and re-applies the delta it prints. ⛔ Not a semantic collision. `origin/main` has been merged **six** times on this branch. `objectstack-ai#19024` (which retired `api-surface-declarations/`) came in early, which is why no `api-surface-declarations/*.txt` appears in this diff. The fifth merge brought **objectstack-ai#19363**, a BREAKING spec change. The **sixth** merge, the head of this body, brought **128 commits** — so the full spec suite was re-run rather than only the generated gates. ⛔ `scripts/pm/os-regen-merge.sh` was NOT used in either round — its `rerun` arm is re-entrant and commits a revert of the operator's own regeneration, filed as **objectstack-ai#19392**. Steps 1–3 of its documented order were performed by hand, against a merge base captured BEFORE the merge and an `origin/main` fetched into an OWNED ref so a sibling's fetch could not move the target mid-round. **The sixth merge decided THREE paths, and only one of them was a conflict.** That gap is worth stating, because resolving only what a conflict probe names would have landed a silent loss: | path | routed | what the merge did | how it was resolved | |:--|:--|:--|:--| | `content/docs/references/index.mdx` | `merge=os-regen` | driver deferred it, exit 0 — **main's side silently dropped** (merged blob `6290447bd9a` == ours, != theirs `7e1f9b6f13e`) | main's side restored into the WORKING TREE ONLY, then regenerated whole | | `content/docs/references/api/package-api.mdx` | `merge=os-regen` | same — **main's side silently dropped** (merged `988bedaa480` == ours, != theirs `d09cd420711`) | same | | `packages/spec/dropped-refinements.baseline.json` | **not** routed | exit 1 — the only real text conflict, one hunk, confined to three summary counters in the `measured` header | both sides' entries unioned, then the build adjudicated |⚠️ **`package-api.mdx` appears in NO conflict list and never could.** It text-merges cleanly driver-free, so a GitHub-condition probe cannot name it; only the both-edited ROUTED set, computed per file against the pre-merge base, finds it — which is exactly what `os-regen-merge.sh` step 2 specifies and what the driver's own `$GIT_DIR/os-regen-pending` record listed. **The regenerated docs are the UNION, proven in both directions** (added/removed line multisets compared as sets): `package-api.mdx` identical at 20 and 14 lines; `index.mdx` identical at 12 and 6 lines, excluding the two running-total lines — a union MUST move a total neither side moves alone, so their disagreement is the signature of a correct union rather than a failure, and the line counts already matched (16/16, 10/10) before excluding them. The total is **re-derived, not arithmetic**: base 1533, this branch alone 1534, main alone 1534, merged tree **1535**, and 1535 is what `gen:schema` itself reports for the merged sources. Main brought `DatasetSelection`, `DatasetCompareTo` and `DatasetTotals` and retired `KernelSecurityScanResult` / `KernelSecurityVulnerability`; this branch brought `FlowFunctionLoweredDeclaration`. All survive, asserted through the published export map of the freshly built dist with a dark control (an invented export name reads undefined). **The ledger was resolved by hand, and that is the only route available.** `dropped-refinements.baseline.json` is hand-edited BY DESIGN with no `gen:` script — its own description states why: *"a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end."* The build VALIDATES it bidirectionally and refuses; it never writes it. Both sides' entries were unioned (union keys missing from the merged file: **none**; merged keys not in the union: **none**; `api/DatasetSelection` arrived from main via objectstack-ai#19638 and survives; main's removal of the `fields.out.keyType` sites is kept — **nine** site lines at the merge base, zero at this head and zero on main (lit control: 204 `"sites"` keys at base; dark control 0).⚠️ The merge round's own prose said *five*; that was a narrative miscount caught by the merge-delta review and re-counted by the seat. The FILE was always right), then `gen:schema` adjudicated and measured 565 dropped sites across 205 published schemas — the union as resolved. One counter the build corrected: `refinementSitesThatDidProject` read 357 and the build measures 366.⚠️ **That correction is filed as objectstack-ai#19681**, because nothing in the repository would have caught it: two of the four `measured` counters have no reader anywhere (lit control — the other two have two readers each, dark control 0), so they can hold any number and every gate stays green. ## Acceptance notes - **noted, not filed**: regenerating `packages/spec/api-surface-declarations/ui.txt` produced a 184-line change that is a pure permutation of its own content — the same union members in a different order, `0 removed, 0 added, 35 reshaped`. Verified as a precedented shape rather than a defect: commit `24d622b94b8`, a spec change touching **zero** files under `packages/spec/src/ui/`, moved the same file by 5 lines whose sorted content is byte-identical. The whole artefact was retired upstream by objectstack-ai#19024 mid-round, so nothing of it survives in this diff and the population is gone. **Carrier: none — the file no longer exists.** - **noted, not filed**: `packages/objectql`'s tests resolve `@objectstack/metadata-protocol` from `dist`, so after merging upstream objectstack-ai#19277 the seven assertions in `protocol-install-package-enable-on-install.test.ts` failed against a stale build of a package this PR never touches; building that one package turns all seven green. A local-environment reading, not a repo defect, and `check:test-source-alias` already owns the aliased/unaliased ledger this sits in. **Carrier: the next seat that runs objectql's suite after a merge — it will see the same red and should build the dependency before reading it as a finding.** ## 维护者速读(草稿) **改了什么** —— 一个包的「包体」在平台里其实要经过四个阶段:作者写的、内存里装配好的、落盘成 artifact 的、注册表记录下来的。前两个早有声明,后两个从来没有。这次把后两个补上:`ArtifactStagePackageBodySchema`(落盘 artifact)和 `RecordStagePackageBodySchema`(注册表记录),放在既有的装配体**旁边**,装配体一个字不动。同时修好一个生产者缺陷:`GET /packages` 以前会把「裸写的函数」整条漏报,现在两种写法都报。 **为什么改** —— 两件事各有代价。其一,装配体里有两个键(`functions`、`hooks`)声明了「可以是一个活的函数」,而 JSON Schema 表达不了函数,于是**任何嵌入它的接口都会整份丢掉自己的 JSON Schema**;读 API 只能把这两个键写成「什么都收、不检查」。其二,我们自己发布的 showcase 声明了 2 个函数,而 `GET /packages` 只报 1 个——机器可读的读门把事实说少了。 **风险与代价(含回滚)** —— 风险集中在一处:那两个键从「什么都收」变成「按声明收」,理论上可能拒掉今天能读的行。已实测三种真实生产者(showcase 的写法、数组写法、artifact 启动装回来的写法)全部照常通过,并且用两次消融证明了这些断言真的会红而不是摆设。⛔ 装配体与 `composeStacks` 未动,所以 `os dev` / `os serve` 的行为不受影响——这正是上一版裁决 B 被撤回的原因,这次没有重蹈。回滚:两个 spec 改动与 objectql 改动互相独立,`git revert` 任一半都不会让另一半变红;最小回滚是把 `package-api.zod.ts` 的那一行绑回装配体,新声明留着不用。 **席位意见** —— **你要做的** —— 这个 PR 的文件里有一份 `skills/**` 的生成文件,按规则整单属于 Tier H,**只有你(或你授权的批准)能让它落地**;AI 席位不会合并、不会排队、不会解除 draft。请看两点:① `@objectstack/objectql` 我打的是 `patch`,理由是「读门修复、不增删 API」,若你认为「载荷多出条目」应算 minor,说一声即可改;② `functions` 的声明式阶段我按「两种 lowered 写法都收」实现(理由写在上面第 1 条),如果裁决本意是只收记录式那一种,也请直接说,那会让 `objectstack build` 今天写出的一种 artifact 被拒。 --- _Generated by [Claude Code](https://claude.ai/code/session_01LvwGppdonww4zGLWZo5rho)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #19011
Reverts PR #18971 (squash commit
d8b12fca9) under the maintainer's ruling C, recorded verbatim on the card: the 12 MiB declaration-text snapshot comes out, and consumer compilation againstspec@mainbecomes the shape gate instead. ⛔ The direction is not re-argued here.The branch was produced by the dispatched
domain:specseat (claim comment on #19011,session_01JbZnqu8bt6YqfJsr9vaFb3); this PR only opens it for review.What lands
git revert d8b12fca9, 31 files, +119 / −238,361:packages/spec/api-surface-declarations/(17 shards, 237,706 lines) and its generatorpackages/spec/scripts/build-api-surface-declarations.ts;packages/spec/api-surface-signatures.json(the 27 hashes) as the interim shape pin;scripts/regen-artifacts.mjs,scripts/pm/check-widening-tells.mjs,scripts/pm/dispatch-gates.mjs(CLASS_EIGHTH),scripts/check-published-files.mjs,.github/workflows/lint.yml,.gitattributes,docs/spec-generated-artifact-sharding.md,packages/spec/package.json(files[]);.changeset/16045-spec-declaration-text-snapshots.md.Faithfulness, measured rather than asserted
Of the 31 files #18971 touched, 30 are restored byte-for-byte to the reverted commit's parent
a48496640— compared by blob sha, not by eye.The one path that is deliberately not restored is
.github/workflows/lint.yml, which keeps the later, unrelatedcheck:release-spec-changesself-test step (#18889, landed after #18971). Reverting that step is not this revert's business; the diff against the parent blob is exactly those 12 lines and nothing else.Merges cleanly into
origin/mainat9ee8e3510(git merge-tree --write-tree, no conflict).Why no changeset
skip-changeset: #18971 was never released — its own changeset was still pending onmain. Reverting the code and its pending changeset leaves the next release byte-identical to what it would have been before #18971 landed, so this PR releases nothing. An empty-frontmatter changeset is not a route (#5471).Landing
⛔ Not a seat's landing. Under the maintainer's second ruling of the same exchange (「修改代码量超过某个行数(比如5000)就应该人工审核」), a 238,480-line PR is maintainer-landed. Opened as a draft; review requested from
GOVERNED_APPROVERS.Not in this card
The consumer-compile gate that replaces the snapshot — objectui's half is filed on objectui; cloud's half is outside this session's write scope and was named to the maintainer in chat.
🤖 Generated with Claude Code